package bot import ( "context" "log" "time" "velox-bot/internal/beta" "velox-bot/internal/commands" "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 betaGuild string // where beta commands are registered right now } 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{ 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 in step with the // settings the owner saves on the dashboard. When the beta server changes, the // beta commands move with it. If the settings can't be read, the last known // ones stay in force (and before the first read, beta stays locked). func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, err error)) { sync := func() { guildID, roleID, err := load(ctx) if err != nil { log.Printf("beta: cannot read settings, keeping the last ones: %v", err) return } beta.Configure(guildID, roleID) if now := beta.GuildID(); now != b.betaGuild { b.registerBetaCommands(b.betaGuild, now) b.betaGuild = now } } sync() go func() { t := time.NewTicker(betaPollEvery) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: sync() } } }() } // registerBetaCommands puts the beta commands in the beta server, and only // there, so nobody else sees them. Commands this bot left there that are no // longer beta (or no longer exist) are removed. When the beta server is also // the bot's main server, its regular commands are kept. If the beta server // changed, the beta commands are taken out of the old one. func (b *Bot) registerBetaCommands(previous, guildID string) { if previous != "" && previous != guildID { isBeta := map[string]bool{} for _, cmd := range commands.BetaCommands { isBeta[cmd.Name] = true } if old, err := b.Session.ApplicationCommands(b.AppID, previous); err != nil { log.Printf("beta: cannot list commands in the previous server: %v", err) } else { for _, cmd := range old { if isBeta[cmd.Name] { if err := b.Session.ApplicationCommandDelete(b.AppID, previous, cmd.ID); err != nil { log.Printf("beta: cannot remove '%v' from the previous server: %v", cmd.Name, err) } } } } } if guildID == "" { return } keep := map[string]bool{} for _, cmd := range b.Commands { if b.GuildID == guildID { keep[cmd.Name] = true } } for _, cmd := range commands.BetaCommands { keep[cmd.Name] = true if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil { log.Printf("beta: cannot create '%v' command: %v", cmd.Name, err) } } existing, err := b.Session.ApplicationCommands(b.AppID, guildID) if err != nil { log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err) return } 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) } } } 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() }