package bot import ( "context" "log" "sync" "time" "velox-bot/internal/beta" "velox-bot/internal/commands" "velox-bot/internal/commands/betacmd" "velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/services" "velox-bot/internal/events" "velox-bot/internal/music" "velox-bot/internal/security" "velox-bot/internal/tickets" "github.com/bwmarrin/discordgo" ) type Bot struct { Session *discordgo.Session AppID string GuildID string Commands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand Services *services.Services MusicRepo *musicrepo.Repo DBHost string LavalinkHost string LavalinkPass string betaRegistered map[string]bool // servers where the beta commands are registered right now betaRoles sync.Map // "guild:user" -> cachedRoles } func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) { session, err := discordgo.New("Bot " + token) if err != nil { return nil, err } // Intents needed for: // - InteractionCreate (slash commands): Guilds // - MessageCreate (leveling): GuildMessages // - GuildMemberAdd (welcome messages): GuildMembers // - VoiceStateUpdate (meeting lobby): GuildVoiceStates session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildBans | discordgo.IntentsGuildMembers | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent | discordgo.IntentsGuildVoiceStates | discordgo.IntentsDirectMessages return &Bot{ betaRegistered: map[string]bool{}, Session: session, AppID: appID, GuildID: guildID, Commands: cmds, Services: services, MusicRepo: musicRepo, DBHost: dbHost, LavalinkHost: lavalinkHost, LavalinkPass: lavalinkPass, }, nil } func (b *Bot) Start() error { if err := b.Session.Open(); err != nil { return err } _ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass) go func() { if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil { log.Printf("music: command listener stopped: %v", err) } }() if st := tickets.Default(); st != nil { go func() { // The connection can drop (database restart, network blip): // reconnect instead of silently ignoring the dashboard from then on. for { err := tickets.StartCommandListener(context.Background(), b.DBHost, b.Session, st) log.Printf("tickets: command listener stopped: %v (retrying in 10s)", err) time.Sleep(10 * time.Second) } }() } if eng := security.Start(b.Session, b.Session.State.User.ID); eng != nil { // Moderators are exempt from heat; their permissions come from the // gateway cache so a message never costs an API call. eng.ChannelPerms = b.Session.State.UserChannelPermissions eng.ResumeRaids(context.Background()) go eng.WatchRaids(context.Background(), 30*time.Second) go eng.WatchSnapshots(context.Background()) go func() { // Reconnect if the database connection drops, instead of // silently ignoring the dashboard from then on. for { err := eng.StartCommandListener(context.Background(), b.DBHost) log.Printf("security: command listener stopped: %v (retrying in 10s)", err) time.Sleep(10 * time.Second) } }() } b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) for _, cmd := range b.Commands { created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) if err != nil { log.Printf("Cannot create '%v' command: %v", cmd.Name, err) continue } b.registeredCommands = append(b.registeredCommands, created) } b.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) { events.HandleChannelDelete(s, c, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { if eng := security.Default(); eng != nil { eng.OnMessage(context.Background(), m) } events.HandleMessageCreate(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) { events.HandleBotAddedOnGuild(s, m, b.Services) // Security runs first: someone it removes (join lock, Join Gate, // raid) gets no welcome message or default role. if eng := security.Default(); eng != nil && m != nil && m.Member != nil { if eng.OnMemberJoin(context.Background(), m.GuildID, m.Member) { events.HandleGuildMemberAddLog(s, m, b.Services) return } } // Ignore bot members for welcome/log onboarding of normal users. if m != nil && m.Member != nil && m.User != nil && m.User.Bot { return } events.HandleGuildMemberAdd(s, m, b.Services) events.HandleGuildMemberAddLog(s, m, b.Services) }) // Anti-nuke and unauthorized bot additions read the audit log as it // happens (needs the Guild Moderation intent and View Audit Log). b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.GuildAuditLogEntryCreate) { if eng := security.Default(); eng != nil && ev.AuditLogEntry != nil { eng.OnAuditLogEntry(context.Background(), ev.GuildID, ev.AuditLogEntry) } }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) { events.HandleGuildMemberRemoveLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) { events.HandleGuildMemberUpdateLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) { events.HandleMessageUpdateLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) { events.HandleMessageDeleteLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) { events.HandleGuildBanAddLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) { events.HandleGuildBanRemoveLog(s, m, b.Services) }) b.Session.AddHandler(func(s *discordgo.Session, t *discordgo.ThreadDelete) { if st := tickets.Default(); st != nil && t.Channel != nil { tickets.ThreadDeleted(context.Background(), st, t.ID) } }) b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { events.HandleVoiceStateUpdate(s, vs, b.Services) music.OnVoiceStateUpdate(vs) }) b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) { music.OnVoiceServerUpdate(ev) }) events.StartScheduleReminderLoop(b.Session, b.Services) events.StartTwitchLiveLoop(b.Session, b.Services) return nil } // betaPollEvery is how often the bot looks for a change to the beta settings // made on the dashboard. const betaPollEvery = 30 * time.Second // WatchBeta keeps the bot's idea of who the beta testers are, and which // servers opted in, in step with the dashboard. When the list of servers // changes, the beta commands follow: they appear in servers that opted in and // are removed from servers that opted out. If the settings can't be read, the // last known ones stay in force (and before the first read, beta stays off). func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, servers []string, err error)) { beta.SetRolesLookup(b.betaMemberRoles) sync := func() { guildID, roleID, servers, err := load(ctx) if err != nil { log.Printf("beta: cannot read settings, keeping the last ones: %v", err) return } beta.Configure(guildID, roleID, servers) b.syncBetaCommands() } sync() go func() { t := time.NewTicker(betaPollEvery) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: sync() } } }() } type cachedRoles struct { roles []string at time.Time } // betaRolesTTL is how long a member's roles in the beta server are remembered // when they had to be fetched from Discord. const betaRolesTTL = 30 * time.Second // betaMemberRoles answers beta.SetRolesLookup: someone's roles in the beta // server. The gateway cache is used when it has them (it is kept current by // member updates), otherwise Discord is asked, at most once per // betaRolesTTL per person. func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) { if m, err := b.Session.State.Member(guildID, userID); err == nil && m != nil { return m.Roles, nil } key := guildID + ":" + userID if v, ok := b.betaRoles.Load(key); ok { if c := v.(cachedRoles); time.Since(c.at) < betaRolesTTL { return c.roles, nil } } m, err := b.Session.GuildMember(guildID, userID) if err != nil { return nil, err } b.betaRoles.Store(key, cachedRoles{roles: m.Roles, at: time.Now()}) return m.Roles, nil } // syncBetaCommands makes the beta commands exist in exactly the servers where // beta features are on (the beta server and the ones that opted in), and // nowhere else. A server whose registration failed is retried next time. func (b *Bot) syncBetaCommands() { want := map[string]bool{} if g := beta.GuildID(); g != "" { want[g] = true } for _, id := range beta.Servers() { want[id] = true } for id := range b.betaRegistered { if !want[id] { b.removeBetaCommands(id) delete(b.betaRegistered, id) } } for id := range want { if !b.betaRegistered[id] && b.addBetaCommands(id) { b.betaRegistered[id] = true } } } // addBetaCommands registers the beta commands in a server. In the beta server // it also removes commands this bot left there that no longer exist (when the // beta server is also the bot's main server, its regular commands are kept). // /beta is for the bot's owner and is only registered in the beta server. It // reports whether everything was registered. func (b *Bot) addBetaCommands(guildID string) bool { isBetaServer := guildID == beta.GuildID() keep := map[string]bool{} for _, cmd := range b.Commands { if b.GuildID == guildID { keep[cmd.Name] = true } } ok := true for _, cmd := range commands.BetaCommands { if cmd.Name == betacmd.Beta.Name && !isBetaServer { continue } keep[cmd.Name] = true if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil { log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err) ok = false } } if !isBetaServer { return ok } existing, err := b.Session.ApplicationCommands(b.AppID, guildID) if err != nil { log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err) return ok } for _, cmd := range existing { if keep[cmd.Name] { continue } if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil { log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err) } } return ok } // removeBetaCommands takes the beta commands out of a server that is no longer // part of the beta, and leaves everything else there alone. func (b *Bot) removeBetaCommands(guildID string) { isBeta := map[string]bool{} for _, cmd := range commands.BetaCommands { isBeta[cmd.Name] = true } existing, err := b.Session.ApplicationCommands(b.AppID, guildID) if err != nil { log.Printf("beta: cannot list commands in %s: %v", guildID, err) return } for _, cmd := range existing { if !isBeta[cmd.Name] { continue } if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil { log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err) } } } func (b *Bot) Close() error { for _, cmd := range b.registeredCommands { if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil { log.Printf("cannot delete '%s' command: %v", cmd.Name, err) } } return b.Session.Close() }