The bot remembers where it registered beta commands (beta_registered) and removes them from servers that are no longer in the beta at the next start. Failed cleanups are retried; a server the bot has left is forgotten. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
439 lines
14 KiB
Go
439 lines
14 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"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 this run registered the beta commands
|
|
betaKnown map[string]bool // servers where they were registered, now or in an earlier run
|
|
betaLoaded bool
|
|
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{},
|
|
betaKnown: 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
|
|
|
|
// BetaStore is where the bot reads the beta settings the dashboard writes,
|
|
// and remembers which servers it registered beta commands in.
|
|
type BetaStore interface {
|
|
GetBetaSettings(ctx context.Context) (guildID, roleID string, err error)
|
|
ListBetaGuilds(ctx context.Context) ([]string, error)
|
|
ListBetaRegistered(ctx context.Context) ([]string, error)
|
|
SetBetaRegistered(ctx context.Context, guildID string, registered bool) error
|
|
}
|
|
|
|
// 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, also if that happened while the bot
|
|
// was offline (it remembers where it registered them). 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, store BetaStore) {
|
|
beta.SetRolesLookup(b.betaMemberRoles)
|
|
sync := func() {
|
|
guildID, roleID, err := store.GetBetaSettings(ctx)
|
|
if err != nil {
|
|
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
|
|
return
|
|
}
|
|
servers, err := store.ListBetaGuilds(ctx)
|
|
if err != nil {
|
|
log.Printf("beta: cannot read the opted-in servers, keeping the last ones: %v", err)
|
|
return
|
|
}
|
|
beta.Configure(guildID, roleID, servers)
|
|
b.syncBetaCommands(ctx, store)
|
|
}
|
|
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. The servers it registered them in are remembered in the
|
|
// database, so a server that opted out while the bot was offline is cleaned up
|
|
// at the next start. A step that failed is retried on the next round.
|
|
func (b *Bot) syncBetaCommands(ctx context.Context, store BetaStore) {
|
|
if !b.betaLoaded {
|
|
known, err := store.ListBetaRegistered(ctx)
|
|
if err != nil {
|
|
log.Printf("beta: cannot read where beta commands were registered: %v", err)
|
|
return
|
|
}
|
|
for _, id := range known {
|
|
b.betaKnown[id] = true
|
|
}
|
|
b.betaLoaded = true
|
|
}
|
|
|
|
want := map[string]bool{}
|
|
if g := beta.GuildID(); g != "" {
|
|
want[g] = true
|
|
}
|
|
for _, id := range beta.Servers() {
|
|
want[id] = true
|
|
}
|
|
for id := range b.betaKnown {
|
|
if want[id] {
|
|
continue
|
|
}
|
|
if !b.removeBetaCommands(id) {
|
|
continue
|
|
}
|
|
if err := store.SetBetaRegistered(ctx, id, false); err != nil {
|
|
log.Printf("beta: cannot forget server %s: %v", id, err)
|
|
continue
|
|
}
|
|
delete(b.betaKnown, id)
|
|
delete(b.betaRegistered, id)
|
|
}
|
|
for id := range want {
|
|
if b.betaRegistered[id] || !b.addBetaCommands(id) {
|
|
continue
|
|
}
|
|
b.betaRegistered[id] = true
|
|
if err := store.SetBetaRegistered(ctx, id, true); err != nil {
|
|
log.Printf("beta: cannot remember server %s: %v", id, err)
|
|
continue
|
|
}
|
|
b.betaKnown[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
|
|
}
|
|
|
|
// goneFromServer reports whether an error from Discord means the bot isn't in
|
|
// that server any more (or it no longer exists), so there is nothing left to
|
|
// clean up there.
|
|
func goneFromServer(err error) bool {
|
|
var rest *discordgo.RESTError
|
|
if errors.As(err, &rest) && rest.Response != nil {
|
|
return rest.Response.StatusCode == http.StatusForbidden || rest.Response.StatusCode == http.StatusNotFound
|
|
}
|
|
return false
|
|
}
|
|
|
|
// removeBetaCommands takes the beta commands out of a server that is no longer
|
|
// part of the beta, and leaves everything else there alone. It reports whether
|
|
// the server is clean (or out of reach for good).
|
|
func (b *Bot) removeBetaCommands(guildID string) bool {
|
|
isBeta := map[string]bool{}
|
|
for _, cmd := range commands.BetaCommands {
|
|
isBeta[cmd.Name] = true
|
|
}
|
|
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
|
if err != nil {
|
|
if goneFromServer(err) {
|
|
return true
|
|
}
|
|
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
|
|
return false
|
|
}
|
|
clean := true
|
|
for _, cmd := range existing {
|
|
if !isBeta[cmd.Name] {
|
|
continue
|
|
}
|
|
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil && !goneFromServer(err) {
|
|
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
|
|
clean = false
|
|
}
|
|
}
|
|
return clean
|
|
}
|
|
|
|
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()
|
|
}
|