package security import ( "context" "errors" "fmt" "io" "sort" "sync" "time" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) // fakeStore keeps everything in memory with the same rules as the SQL. type fakeStore struct { mu sync.Mutex settings map[int64]*securityrepo.Settings trusted map[int64][]securityrepo.Trusted quarantines map[string]securityrepo.Quarantine lockdowns map[string]securityrepo.Lockdown events []securityrepo.Event cmds []securityrepo.Command done map[int64]bool raids map[int64]securityrepo.Raid caught map[int64][]securityrepo.Caught snapshots []securityrepo.Snapshot } func newStore() *fakeStore { return &fakeStore{ settings: map[int64]*securityrepo.Settings{}, trusted: map[int64][]securityrepo.Trusted{}, quarantines: map[string]securityrepo.Quarantine{}, lockdowns: map[string]securityrepo.Lockdown{}, done: map[int64]bool{}, raids: map[int64]securityrepo.Raid{}, caught: map[int64][]securityrepo.Caught{}, } } func (f *fakeStore) SaveRaid(_ context.Context, x securityrepo.Raid) error { f.mu.Lock() defer f.mu.Unlock() if old, ok := f.raids[x.GuildID]; ok { x.StartedAt = old.StartedAt } f.raids[x.GuildID] = x return nil } func (f *fakeStore) ListRaids(context.Context) ([]securityrepo.Raid, error) { f.mu.Lock() defer f.mu.Unlock() var out []securityrepo.Raid for _, x := range f.raids { out = append(out, x) } return out, nil } func (f *fakeStore) DeleteRaid(_ context.Context, g int64) error { f.mu.Lock() defer f.mu.Unlock() delete(f.raids, g) delete(f.caught, g) return nil } func (f *fakeStore) AddRaidCaught(_ context.Context, g int64, c securityrepo.Caught) error { f.mu.Lock() defer f.mu.Unlock() for _, x := range f.caught[g] { if x.UserID == c.UserID { return nil } } f.caught[g] = append(f.caught[g], c) return nil } func (f *fakeStore) ListRaidCaught(_ context.Context, g int64, limit int) ([]securityrepo.Caught, error) { f.mu.Lock() defer f.mu.Unlock() out := f.caught[g] if len(out) > limit { out = out[:limit] } return append([]securityrepo.Caught(nil), out...), nil } func (f *fakeStore) SaveSnapshot(_ context.Context, s securityrepo.Snapshot) error { f.mu.Lock() defer f.mu.Unlock() s.ID = int64(len(f.snapshots) + 1) s.CreatedAt = time.Date(2026, 10, 20, 6, 0, 0, 0, time.UTC) f.snapshots = append(f.snapshots, s) return nil } func (f *fakeStore) LatestSnapshot(_ context.Context, g int64) (*securityrepo.Snapshot, error) { f.mu.Lock() defer f.mu.Unlock() for i := len(f.snapshots) - 1; i >= 0; i-- { if f.snapshots[i].GuildID == g { s := f.snapshots[i] return &s, nil } } return nil, nil } func (f *fakeStore) GuildsDueForSnapshot(context.Context, time.Duration) ([]int64, error) { return nil, nil } func key(a, b int64) string { return fmt.Sprint(a, "/", b) } func (f *fakeStore) set(g int64) *securityrepo.Settings { f.mu.Lock() defer f.mu.Unlock() s, ok := f.settings[g] if !ok { s = securityrepo.Defaults(g) s.Configured = true f.settings[g] = s } return s } func (f *fakeStore) GetSettings(_ context.Context, g int64) (*securityrepo.Settings, error) { f.mu.Lock() defer f.mu.Unlock() if s, ok := f.settings[g]; ok { cp := *s return &cp, nil } return securityrepo.Defaults(g), nil } func (f *fakeStore) SetEnabled(_ context.Context, g int64, feature string, on bool) error { s := f.set(g) switch feature { case securityrepo.FeatureJoinGate: s.JoinGateEnabled = on case securityrepo.FeatureAntiRaid: s.AntiRaidEnabled = on case securityrepo.FeatureAntiNuke: s.AntiNukeEnabled = on } return nil } func (f *fakeStore) SetAlerts(_ context.Context, g, ch, role int64) error { s := f.set(g) s.AlertChannelID, s.AlertRoleID = ch, role return nil } func (f *fakeStore) SetQuarantineRole(_ context.Context, g, role int64) error { f.set(g).QuarantineRoleID = role return nil } func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error { f.set(g).JoinLock = mode return nil } func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) { f.mu.Lock() defer f.mu.Unlock() return append([]securityrepo.Trusted(nil), f.trusted[g]...), nil } func (f *fakeStore) AddTrusted(_ context.Context, g int64, t securityrepo.Trusted) error { f.mu.Lock() defer f.mu.Unlock() f.trusted[g] = append(f.trusted[g], t) return nil } func (f *fakeStore) RemoveTrusted(_ context.Context, g, target int64) (bool, error) { f.mu.Lock() defer f.mu.Unlock() var keep []securityrepo.Trusted found := false for _, t := range f.trusted[g] { if t.TargetID == target { found = true continue } keep = append(keep, t) } f.trusted[g] = keep return found, nil } func (f *fakeStore) SaveQuarantine(_ context.Context, q securityrepo.Quarantine) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.quarantines[key(q.GuildID, q.UserID)]; !ok { f.quarantines[key(q.GuildID, q.UserID)] = q } return nil } func (f *fakeStore) GetQuarantine(_ context.Context, g, u int64) (*securityrepo.Quarantine, error) { f.mu.Lock() defer f.mu.Unlock() if q, ok := f.quarantines[key(g, u)]; ok { return &q, nil } return nil, nil } func (f *fakeStore) ListQuarantines(_ context.Context, g int64) ([]securityrepo.Quarantine, error) { f.mu.Lock() defer f.mu.Unlock() var out []securityrepo.Quarantine for _, q := range f.quarantines { if q.GuildID == g { out = append(out, q) } } return out, nil } func (f *fakeStore) DeleteQuarantine(_ context.Context, g, u int64) error { f.mu.Lock() defer f.mu.Unlock() delete(f.quarantines, key(g, u)) return nil } func (f *fakeStore) SaveLockdown(_ context.Context, l securityrepo.Lockdown) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.lockdowns[key(l.GuildID, l.ChannelID)]; !ok { f.lockdowns[key(l.GuildID, l.ChannelID)] = l } return nil } func (f *fakeStore) GetLockdown(_ context.Context, g, ch int64) (*securityrepo.Lockdown, error) { f.mu.Lock() defer f.mu.Unlock() if l, ok := f.lockdowns[key(g, ch)]; ok { return &l, nil } return nil, nil } func (f *fakeStore) ListLockdowns(_ context.Context, g int64, serverOnly bool) ([]securityrepo.Lockdown, error) { f.mu.Lock() defer f.mu.Unlock() var out []securityrepo.Lockdown for _, l := range f.lockdowns { if l.GuildID == g && (!serverOnly || l.ServerWide) { out = append(out, l) } } sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) return out, nil } func (f *fakeStore) DeleteLockdown(_ context.Context, g, ch int64) error { f.mu.Lock() defer f.mu.Unlock() delete(f.lockdowns, key(g, ch)) return nil } func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, e) return nil } func (f *fakeStore) eventKinds() []string { f.mu.Lock() defer f.mu.Unlock() var out []string for _, e := range f.events { out = append(out, e.Kind) } return out } func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]securityrepo.Command, error) { f.mu.Lock() defer f.mu.Unlock() var out []securityrepo.Command for _, c := range f.cmds { if !f.done[c.ID] { out = append(out, c) } } return out, nil } func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error { f.mu.Lock() defer f.mu.Unlock() f.done[id] = true return nil } // fakeDiscord is a tiny in-memory guild. type fakeDiscord struct { mu sync.Mutex ownerID string roles []*discordgo.Role members map[string]*discordgo.Member users map[string]*discordgo.User channels map[string]*discordgo.Channel kicked []string banned []string timeouts map[string]*time.Time sent []*discordgo.MessageSend sentTo []string files map[string]string requests []string // "METHOD url body" deletedMsgs []string nextID int failEdits error rejectRole string // edits that assign this role fail, like a role above Velox's lookups int // member and role lookups, to check harmless entries cost nothing closedDMs map[string]bool guildName string } const guild = "100" func newDiscord() *fakeDiscord { return &fakeDiscord{ ownerID: "1", roles: []*discordgo.Role{ {ID: guild, Name: "@everyone", Position: 0}, {ID: "200", Name: "Admin", Position: 5, Permissions: discordgo.PermissionAdministrator}, {ID: "201", Name: "Mod", Position: 3, Permissions: discordgo.PermissionKickMembers}, {ID: "202", Name: "Member", Position: 1}, {ID: "203", Name: "Quarantine", Position: 2}, {ID: "204", Name: "Booster", Position: 1, Managed: true}, {ID: "299", Name: "Velox", Position: 9, Managed: true, Permissions: discordgo.PermissionAdministrator}, }, members: map[string]*discordgo.Member{ "1": {User: &discordgo.User{ID: "1", Username: "owner"}}, "999": {User: &discordgo.User{ID: "999", Username: "velox", Bot: true}, Roles: []string{"299"}}, }, users: map[string]*discordgo.User{}, channels: map[string]*discordgo.Channel{}, timeouts: map[string]*time.Time{}, files: map[string]string{}, } } func (f *fakeDiscord) addMember(id string, roles ...string) *discordgo.Member { f.mu.Lock() defer f.mu.Unlock() m := &discordgo.Member{User: &discordgo.User{ID: id, Username: "user" + id, Avatar: "a"}, Roles: roles} f.members[id] = m return m } func (f *fakeDiscord) Guild(g string, _ ...discordgo.RequestOption) (*discordgo.Guild, error) { return &discordgo.Guild{ID: g, OwnerID: f.ownerID, Name: f.guildName}, nil } func (f *fakeDiscord) UserChannelCreate(u string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) { f.mu.Lock() defer f.mu.Unlock() if f.closedDMs[u] { return nil, errors.New("Cannot send messages to this user") } return &discordgo.Channel{ID: "dm-" + u, Type: discordgo.ChannelTypeDM}, nil } // dmsTo lists who got a DM, in order. func (f *fakeDiscord) dmsTo() []string { f.mu.Lock() defer f.mu.Unlock() var out []string for _, ch := range f.sentTo { if len(ch) > 3 && ch[:3] == "dm-" { out = append(out, ch[3:]) } } return out } func (f *fakeDiscord) User(id string, _ ...discordgo.RequestOption) (*discordgo.User, error) { f.mu.Lock() defer f.mu.Unlock() if u, ok := f.users[id]; ok { return u, nil } return nil, errors.New("unknown user") } func (f *fakeDiscord) GuildRoles(string, ...discordgo.RequestOption) ([]*discordgo.Role, error) { f.mu.Lock() defer f.mu.Unlock() f.lookups++ return f.roles, nil } func (f *fakeDiscord) GuildChannels(g string, _ ...discordgo.RequestOption) ([]*discordgo.Channel, error) { f.mu.Lock() defer f.mu.Unlock() var out []*discordgo.Channel for _, c := range f.channels { if c.GuildID == g { out = append(out, c) } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (f *fakeDiscord) GuildMember(_, u string, _ ...discordgo.RequestOption) (*discordgo.Member, error) { f.mu.Lock() defer f.mu.Unlock() f.lookups++ if m, ok := f.members[u]; ok { cp := *m cp.Roles = append([]string(nil), m.Roles...) return &cp, nil } return nil, errors.New("unknown member") } func (f *fakeDiscord) GuildMemberEdit(_, u string, data *discordgo.GuildMemberParams, _ ...discordgo.RequestOption) (*discordgo.Member, error) { f.mu.Lock() defer f.mu.Unlock() if f.failEdits != nil { return nil, f.failEdits } if data.Roles != nil && f.rejectRole != "" { for _, r := range *data.Roles { if r == f.rejectRole { return nil, errors.New("403 Missing Permissions") } } } m, ok := f.members[u] if !ok { return nil, errors.New("unknown member") } if data.Roles != nil { m.Roles = append([]string(nil), (*data.Roles)...) } return m, nil } func (f *fakeDiscord) GuildMemberTimeout(_, u string, until *time.Time, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() f.timeouts[u] = until return nil } func (f *fakeDiscord) GuildMemberDeleteWithReason(_, u, _ string, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() f.kicked = append(f.kicked, u) delete(f.members, u) return nil } func (f *fakeDiscord) GuildBanCreateWithReason(_, u, _ string, _ int, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() f.banned = append(f.banned, u) delete(f.members, u) return nil } func (f *fakeDiscord) Channel(id string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) { f.mu.Lock() defer f.mu.Unlock() if c, ok := f.channels[id]; ok { cp := *c cp.PermissionOverwrites = nil for _, o := range c.PermissionOverwrites { oc := *o cp.PermissionOverwrites = append(cp.PermissionOverwrites, &oc) } return &cp, nil } return nil, errors.New("unknown channel") } func (f *fakeDiscord) ChannelPermissionSet(ch, target string, t discordgo.PermissionOverwriteType, allow, deny int64, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() c := f.channels[ch] for _, o := range c.PermissionOverwrites { if o.ID == target { o.Allow, o.Deny = allow, deny return nil } } c.PermissionOverwrites = append(c.PermissionOverwrites, &discordgo.PermissionOverwrite{ID: target, Type: t, Allow: allow, Deny: deny}) return nil } func (f *fakeDiscord) ChannelPermissionDelete(ch, target string, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() c := f.channels[ch] var keep []*discordgo.PermissionOverwrite for _, o := range c.PermissionOverwrites { if o.ID != target { keep = append(keep, o) } } c.PermissionOverwrites = keep return nil } func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) { f.mu.Lock() defer f.mu.Unlock() f.sentTo = append(f.sentTo, ch) for _, file := range data.Files { raw, _ := io.ReadAll(file.Reader) f.files[file.Name] = string(raw) } f.sent = append(f.sent, data) return &discordgo.Message{ID: "m"}, nil } func (f *fakeDiscord) RequestWithBucketID(method, url string, data interface{}, _ string, _ ...discordgo.RequestOption) ([]byte, error) { f.mu.Lock() defer f.mu.Unlock() f.requests = append(f.requests, fmt.Sprintf("%s %s %v", method, url, data)) return nil, nil } // sentCount counts channel alerts (not DMs). func (f *fakeDiscord) sentCount() int { return len(f.channelSends()) } // channelSends lists messages sent to channels, leaving out DMs. func (f *fakeDiscord) channelSends() []*discordgo.MessageSend { f.mu.Lock() defer f.mu.Unlock() var out []*discordgo.MessageSend for i, m := range f.sent { if i >= len(f.sentTo) || len(f.sentTo[i]) < 3 || f.sentTo[i][:3] != "dm-" { out = append(out, m) } } return out } func (f *fakeDiscord) newID() string { f.nextID++ return fmt.Sprint(8000 + f.nextID) } func (f *fakeDiscord) ChannelMessageDelete(ch, msg string, _ ...discordgo.RequestOption) error { f.mu.Lock() defer f.mu.Unlock() f.deletedMsgs = append(f.deletedMsgs, msg) return nil } func (f *fakeDiscord) GuildRoleCreate(_ string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) { f.mu.Lock() defer f.mu.Unlock() r := &discordgo.Role{ID: f.newID(), Name: p.Name} if p.Permissions != nil { r.Permissions = *p.Permissions } f.roles = append(f.roles, r) return r, nil } func (f *fakeDiscord) GuildRoleEdit(_, roleID string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) { f.mu.Lock() defer f.mu.Unlock() for _, r := range f.roles { if r.ID == roleID { if p.Permissions != nil { r.Permissions = *p.Permissions } return r, nil } } return nil, errors.New("unknown role") } func (f *fakeDiscord) GuildChannelCreateComplex(g string, d discordgo.GuildChannelCreateData, _ ...discordgo.RequestOption) (*discordgo.Channel, error) { f.mu.Lock() defer f.mu.Unlock() c := &discordgo.Channel{ID: f.newID(), GuildID: g, Name: d.Name, Type: d.Type, ParentID: d.ParentID, PermissionOverwrites: d.PermissionOverwrites} f.channels[c.ID] = c return c, nil }