diff --git a/bot/events.go b/bot/events.go index e187ac1..27f0dd9 100644 --- a/bot/events.go +++ b/bot/events.go @@ -20,6 +20,7 @@ type MessageEvent struct { ArchiveEventEvents []ArchiveEventEvent `gorm:"foreignKey:UUID"` } +// A InteractionEvent when a user interacts with an Embed type InteractionEvent struct { CreatedAt time.Time UUID string `gorm:"primaryKey"` @@ -32,7 +33,7 @@ type InteractionEvent struct { ArchiveEventEvents []ArchiveEventEvent `gorm:"foreignKey:UUID"` } -// Every successful ArchiveEventEvent will come from a message. +// Every successful ArchiveEventEvent will come from a message type ArchiveEventEvent struct { CreatedAt time.Time UUID string `gorm:"primaryKey"` @@ -46,7 +47,7 @@ type ArchiveEventEvent struct { } // This is the representation of request and response URLs from users or -// the Archiver API. +// the Archiver API type ArchiveEvent struct { CreatedAt time.Time UUID string `gorm:"primaryKey"` @@ -60,13 +61,13 @@ type ArchiveEvent struct { Cached bool } -// createMessageEvent logs a given message event into the database. +// createMessageEvent logs a given message event into the database func (bot *ArchiverBot) createMessageEvent(m MessageEvent) { m.UUID = uuid.New().String() bot.DB.Create(&m) } -// createInteractionEvent logs a given message event into the database. +// createInteractionEvent logs a given message event into the database func (bot *ArchiverBot) createInteractionEvent(i InteractionEvent) { i.UUID = uuid.New().String() bot.DB.Create(&i) diff --git a/bot/functions.go b/bot/functions.go index f068f6c..cb4cff8 100644 --- a/bot/functions.go +++ b/bot/functions.go @@ -22,8 +22,8 @@ const ( ) // handleArchiveRequest takes a Discord session and a message string and -// calls go-archiver with a []string of URLs parsed from the message. -// It then sends an embed with the resulting archived URLs. +// calls go-archiver with a []string of URLs parsed from the message +// It then sends an embed with the resulting archived URLs // TODO: break out into more functions? func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, newSnapshot bool) ( replies []*discordgo.MessageSend, errs []error) { @@ -31,7 +31,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne typingStop := make(chan bool, 1) go bot.typeInChannel(typingStop, r.ChannelID) - // If true, this is a DM. + // If true, this is a DM if r.GuildID == "" { typingStop <- true replies = []*discordgo.MessageSend{ @@ -43,7 +43,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne } sc := bot.getServerConfig(r.GuildID) - if !sc.ArchiveEnabled { + if sc.ArchiveEnabled.Valid && !sc.ArchiveEnabled.Bool { log.Info("URLs were not archived because automatic archive is not enabled") typingStop <- true return replies, errs @@ -92,7 +92,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne log.Debug("URLs parsed from message: ", strings.Join(messageUrls, ", ")) // This UUID will be used to tie together the ArchiveEventEvent, - // the archiveRequestUrls and the archiveResponseUrls. + // the archiveRequestUrls and the archiveResponseUrls archiveEventUUID := uuid.New().String() var archives []ArchiveEvent @@ -102,13 +102,13 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne log.Error("unable to get domain name for url: ", url) } - // See if there is a response URL for a given request URL in the database. + // See if there is a response URL for a given request URL in the database cachedArchiveEvents := []ArchiveEvent{} bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{RequestURL: url, Cached: false}).Find(&cachedArchiveEvents) var responseUrl, responseDomainName string // If we have a response, create a new ArchiveEvent with it, - // marking it as cached. + // marking it as cached for _, cachedArchiveEvent := range cachedArchiveEvents { if cachedArchiveEvent.ResponseURL != "" && cachedArchiveEvent.ResponseDomainName != "" { responseUrl = cachedArchiveEvent.ResponseURL @@ -134,7 +134,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne } // We have not already archived this URL, so build an object - // for doing so. + // for doing so log.Debug("url was not cached: ", url) archives = append(archives, ArchiveEvent{ UUID: uuid.New().String(), @@ -152,9 +152,9 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne if archive.ResponseURL == "" { log.Debug("need to call archive.org api for ", archive.RequestURL) - // This will always try to archive the page if not found. - url, err := goarchive.GetLatestURL(archive.RequestURL, sc.RetryAttempts, - sc.AlwaysArchiveFirst || newSnapshot, bot.Config.Cookie) + // This will always try to archive the page if not found + url, err := goarchive.GetLatestURL(archive.RequestURL, uint(sc.RetryAttempts.Int32), + sc.AlwaysArchiveFirst.Bool || newSnapshot, bot.Config.Cookie) if err != nil { log.Errorf("error archiving url: %v", err) url = fmt.Sprint("%w", errors.Unwrap(err)) @@ -174,7 +174,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne archivedLinks = append(archivedLinks, url) } else { // We have a response URL, so add that to the links to be used - // in the message. + // in the message archivedLinks = append(archivedLinks, archive.ResponseURL) } } @@ -235,7 +235,7 @@ func (bot *ArchiverBot) handleArchiveRequest(r *discordgo.MessageReactionAdd, ne } if link != "" { - if sc.ShowDetails { + if sc.ShowDetails.Valid && sc.ShowDetails.Bool { embeds[0].Fields = []*discordgo.MessageEmbedField{ { Name: "Oldest Archived Copy", diff --git a/bot/handlers.go b/bot/handlers.go index 126f1ae..01e9688 100644 --- a/bot/handlers.go +++ b/bot/handlers.go @@ -10,6 +10,8 @@ import ( "gorm.io/gorm" ) +// ArchiverBot is the main type passed around throughout the code +// It has many functions for overall bot management type ArchiverBot struct { DB *gorm.DB DG *discordgo.Session @@ -17,6 +19,8 @@ type ArchiverBot struct { StartingUp bool } +// ArchiverBotConfig is attached to ArchiverBot so config settings can be +// accessed easily type ArchiverBotConfig struct { AdminIds []string `env:"ADMINISTRATOR_IDS"` DBHost string `env:"DB_HOST"` @@ -28,8 +32,9 @@ type ArchiverBotConfig struct { Cookie string `env:"COOKIE"` } -// BotReadyHandler is called when the bot is considered ready to use the Discord session. +// BotReadyHandler is called when the bot is considered ready to use the Discord session func (bot *ArchiverBot) BotReadyHandler(s *discordgo.Session, r *discordgo.Ready) { + // Register all servers the bot is active in for _, g := range r.Guilds { err := bot.registerOrUpdateServer(g) if err != nil { @@ -37,13 +42,15 @@ func (bot *ArchiverBot) BotReadyHandler(s *discordgo.Session, r *discordgo.Ready } } - // Use this to clean up commands if IDs have changed. + bot.updateServerRegistrations(r.Guilds) + + // Use this to clean up commands if IDs have changed // TODO remove later if unnecessary // log.Debug("removing all commands") // bot.deleteAllCommands() + // globals.RegisteredCommands, err = bot.DG.ApplicationCommandBulkOverwrite(bot.DG.State.User.ID, "", globals.Commands) log.Debug("registering slash commands") var err error - // globals.RegisteredCommands, err = bot.DG.ApplicationCommandBulkOverwrite(bot.DG.State.User.ID, "", globals.Commands) existingCommands, err := bot.DG.ApplicationCommands(bot.DG.State.User.ID, "") for _, cmd := range globals.Commands { for _, existingCmd := range existingCommands { @@ -79,7 +86,7 @@ func (bot *ArchiverBot) BotReadyHandler(s *discordgo.Session, r *discordgo.Ready } // GuildCreateHandler is called whenever the bot joins a new guild. It is also lazily called upon initial -// connection to Discord. +// connection to Discord func (bot *ArchiverBot) GuildCreateHandler(s *discordgo.Session, gc *discordgo.GuildCreate) { if gc.Guild.Unavailable { return @@ -92,7 +99,7 @@ func (bot *ArchiverBot) GuildCreateHandler(s *discordgo.Session, gc *discordgo.G } // This function will be called every time a new react is created on any message -// that the authenticated bot has access to. +// that the authenticated bot has access to func (bot *ArchiverBot) MessageReactionAddHandler(s *discordgo.Session, r *discordgo.MessageReactionAdd) { if r.MessageReaction.Emoji.Name == "🏛️" { var m *discordgo.Message @@ -104,7 +111,7 @@ func (bot *ArchiverBot) MessageReactionAddHandler(s *discordgo.Session, r *disco return } // Create a fake message so that we can handle reacts - // and interactions. + // and interactions m = &discordgo.Message{ ID: r.MessageReaction.MessageID, Member: &discordgo.Member{ @@ -117,7 +124,7 @@ func (bot *ArchiverBot) MessageReactionAddHandler(s *discordgo.Session, r *disco } } else { // Create a fake message so that we can handle reacts - // and interactions. + // and interactions m = &discordgo.Message{ ID: r.MessageID, Member: &discordgo.Member{ @@ -208,8 +215,8 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In guild.Name + "(" + guild.ID + ")" } else { log.Debug("handling stats DM request") - // We can be sure now the request was a direct message. - // Deny by default. + // We can be sure now the request was a direct message + // Deny by default administrator := false out: @@ -220,7 +227,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In // This prevents us from checking all IDs now that // we found a match but is a fairly ineffectual // optimization since config.AdminIds will probably - // only have dozens of IDs at most. + // only have dozens of IDs at most break out } } @@ -259,7 +266,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In Embeds: []*discordgo.MessageEmbed{ { Title: "🏛️ Archive.org Bot Stats", - Fields: structToPrettyDiscordFields(stats), + Fields: structToPrettyDiscordFields(stats, directMessage), Color: globals.FrenchGray, }, }, @@ -272,7 +279,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In }, globals.Settings: func(s *discordgo.Session, i *discordgo.InteractionCreate) { log.Debug("handling settings request") - // This is a DM, so settings cannot be changed. + // This is a DM, so settings cannot be changed if i.GuildID == "" { err := bot.DG.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -299,13 +306,14 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In }) sc := bot.getServerConfig(i.GuildID) + resp := bot.SettingsIntegrationResponse(sc) err = bot.DG.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: bot.SettingsIntegrationResponse(sc), + Data: resp, }) if err != nil { - log.Errorf("error responding to slash command"+globals.Settings+", err: %v", err) + log.Errorf("error responding to slash command "+globals.Settings+", err: %v", err) } } @@ -401,7 +409,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In sc := bot.getServerConfig(i.GuildID) var interactionErr error - inverse := !sc.ArchiveEnabled + inverse := sc.ArchiveEnabled.Valid && !sc.ArchiveEnabled.Bool sc, ok := bot.updateServerSetting(i.GuildID, "archive_enabled", inverse) guild, err := bot.DG.Guild(i.Interaction.GuildID) @@ -440,7 +448,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In }, globals.Details: func(s *discordgo.Session, i *discordgo.InteractionCreate) { sc := bot.getServerConfig(i.GuildID) - inverse := !sc.ShowDetails + inverse := sc.ShowDetails.Valid && !sc.ShowDetails.Bool var interactionErr error sc, ok := bot.updateServerSetting(i.GuildID, "show_details", inverse) @@ -476,7 +484,7 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In }, globals.AlwaysArchiveFirst: func(s *discordgo.Session, i *discordgo.InteractionCreate) { sc := bot.getServerConfig(i.GuildID) - inverse := !sc.AlwaysArchiveFirst + inverse := sc.AlwaysArchiveFirst.Valid && !sc.AlwaysArchiveFirst.Bool var interactionErr error sc, ok := bot.updateServerSetting(i.GuildID, "always_archive_first", inverse) @@ -512,7 +520,8 @@ func (bot *ArchiverBot) InteractionHandler(s *discordgo.Session, i *discordgo.In }, globals.RemoveRetry: func(s *discordgo.Session, i *discordgo.InteractionCreate) { sc := bot.getServerConfig(i.GuildID) - inverse := !sc.RemoveRetries + // If the value isn't valid, the setting should end up disabled + inverse := sc.RemoveRetry.Valid && !sc.RemoveRetry.Bool var interactionErr error sc, ok := bot.updateServerSetting(i.GuildID, "remove_retry", inverse) diff --git a/bot/messaging.go b/bot/messaging.go index eaa4939..9e2ce1e 100644 --- a/bot/messaging.go +++ b/bot/messaging.go @@ -1,6 +1,8 @@ package bot import ( + "strconv" + "strings" "time" "github.com/bwmarrin/discordgo" @@ -57,7 +59,20 @@ func (bot *ArchiverBot) removeRetryButtonAfterSleep(message *discordgo.Message) } sc := bot.getServerConfig(guild.ID) - time.Sleep(time.Duration(sc.RemoveRetriesDelay)) + var sleep int32 + if sc.RemoveRetriesDelay.Valid { + sleep = sc.RemoveRetriesDelay.Int32 + } else { + field := "RemoveRetriesDelay" + log.Debugf("%s was not set, getting gorm default", field) + gormDefault := getTagValue(ServerConfig{}, field, "gorm") + if value, err := strconv.ParseInt(strings.Split(gormDefault, ":")[1], 10, 32); err != nil { + log.Errorf("unable to get default gorm value for %s", field) + } else { + sleep = int32(value) + } + } + time.Sleep(time.Duration(sleep) * time.Second) me := discordgo.MessageEdit{ // Remove the components (button) Components: []discordgo.MessageComponent{}, @@ -67,7 +82,7 @@ func (bot *ArchiverBot) removeRetryButtonAfterSleep(message *discordgo.Message) } log.Debugf("removing reply button (waited %vs) for message ID %s in channel %s, guild: %s(%s)", - sc.RemoveRetriesDelay, message.ID, message.ChannelID, guild.Name, guild.ID) + sleep, message.ID, message.ChannelID, guild.Name, guild.ID) _, err := bot.DG.ChannelMessageEditComplex(&me) if err != nil { log.Errorf("unable to remove retry button on message id %v, server: %s(%s): %v, ", diff --git a/bot/servers.go b/bot/servers.go index fd4f3ab..1d4c26f 100644 --- a/bot/servers.go +++ b/bot/servers.go @@ -1,6 +1,7 @@ package bot import ( + "database/sql" "fmt" "time" @@ -12,38 +13,34 @@ type ServerRegistration struct { DiscordId string `gorm:"primaryKey"` Name string UpdatedAt time.Time + Active ConfigBool `pretty:"Bot is active in the server" gorm:"default:true"` Config ServerConfig `gorm:"foreignKey:DiscordId"` } +type ConfigBool struct { + sql.NullBool +} + +type ConfigInt32 struct { + sql.NullInt32 +} + type ServerConfig struct { - DiscordId string `gorm:"primaryKey" pretty:"Server ID"` - Name string `pretty:"Server Name"` - ArchiveEnabled bool `pretty:"Bot enabled"` - AlwaysArchiveFirst bool `pretty:"Archive the page first (slower)"` - ShowDetails bool `pretty:"Show extra details"` - RemoveRetries bool `pretty:"Remove the retry button automatically"` - RetryAttempts uint `pretty:"Number of attempts to archive a URL"` - RemoveRetriesDelay uint `pretty:"Seconds to wait to remove retry button"` + DiscordId string `gorm:"primaryKey" pretty:"Server ID"` + Name string `pretty:"Server Name" gorm:"default:default"` + ArchiveEnabled ConfigBool `pretty:"Bot enabled" gorm:"default:true"` + AlwaysArchiveFirst ConfigBool `pretty:"Archive the page first (slower)" gorm:"default:false"` + ShowDetails ConfigBool `pretty:"Show extra details" gorm:"default:true"` + RemoveRetry ConfigBool `pretty:"Remove the retry button automatically" gorm:"default:true"` + RetryAttempts ConfigInt32 `pretty:"Number of attempts to archive a URL" gorm:"default:1"` + RemoveRetriesDelay ConfigInt32 `pretty:"Seconds to wait to remove retry button" gorm:"default:30"` UpdatedAt time.Time } -var ( - defaultServerConfig ServerConfig = ServerConfig{ - DiscordId: "0", - Name: "default", - ArchiveEnabled: true, - AlwaysArchiveFirst: false, - ShowDetails: true, - RemoveRetries: true, - RetryAttempts: 1, - RemoveRetriesDelay: 30, - } - - archiverRepoUrl string = "https://github.com/tyzbit/go-discord-archiver" -) +const archiverRepoUrl string = "https://github.com/tyzbit/go-discord-archiver" // registerOrUpdateServer checks if a guild is already registered in the database. If not, -// it creates it with sensibile defaults. +// it creates it with sensibile defaults func (bot *ArchiverBot) registerOrUpdateServer(g *discordgo.Guild) error { // Do a lookup for the full guild object guild, err := bot.DG.Guild(g.ID) @@ -53,25 +50,35 @@ func (bot *ArchiverBot) registerOrUpdateServer(g *discordgo.Guild) error { var registration ServerRegistration bot.DB.Find(®istration, g.ID) + active := ConfigBool{sql.NullBool{Bool: true}} // The server registration does not exist, so we will create with defaults if (registration == ServerRegistration{}) { log.Info("creating registration for new server: ", guild.Name, "(", g.ID, ")") - sc := defaultServerConfig - sc.Name = guild.Name tx := bot.DB.Create(&ServerRegistration{ DiscordId: g.ID, Name: guild.Name, + Active: active, UpdatedAt: time.Now(), - Config: sc, + Config: ServerConfig{ + Name: guild.Name, + }, }) - // We only expect one server to be updated at a time. Otherwise, return an error. + // We only expect one server to be updated at a time. Otherwise, return an error if tx.RowsAffected != 1 { return fmt.Errorf("did not expect %v rows to be affected updating "+ "server registration for server: %v(%v)", fmt.Sprintf("%v", tx.RowsAffected), guild.Name, g.ID) } } + // Sort of a migration and also a catch-all for registrations that + // are not properly saved in the database + if !registration.Active.Valid { + bot.DB.Model(&ServerRegistration{}). + Where(&ServerRegistration{DiscordId: registration.DiscordId}). + Updates(&ServerRegistration{Active: active}) + } + err = bot.updateServersWatched() if err != nil { return fmt.Errorf("unable to update servers watched: %v", err) @@ -80,14 +87,43 @@ func (bot *ArchiverBot) registerOrUpdateServer(g *discordgo.Guild) error { return nil } -// getServerConfig takes a guild ID and returns a ServerConfig object for that server. -// If the config isn't found, it returns a default config. +// updateServerRegistrations goes through every server registration and +// updates the DB as to whether or not it's active +func (bot *ArchiverBot) updateServerRegistrations(activeGuilds []*discordgo.Guild) { + var sr []ServerRegistration + bot.DB.Find(&sr) + active := ConfigBool{sql.NullBool{Bool: true}} + inactive := ConfigBool{sql.NullBool{Bool: false}} + + // Update all registrations for whether or not the server is active + for _, reg := range sr { + // If there is no guild in r.Guilds, then we havea config + // for a server we're not in anymore + reg.Active = inactive + for _, g := range activeGuilds { + if g.ID == reg.DiscordId { + reg.Active = active + } + } + + // Now the registration is accurate, update the DB + tx := bot.DB.Model(&ServerRegistration{}).Where(&ServerRegistration{DiscordId: reg.DiscordId}). + Updates(reg) + + if tx.RowsAffected != 1 { + log.Errorf("unexpected number of rows affected updating server registration, id: %s, rows updated: %v", + reg.DiscordId, tx.RowsAffected) + } + } +} + +// getServerConfig takes a guild ID and returns a ServerConfig object for that server +// If the config isn't found, it returns a default config func (bot *ArchiverBot) getServerConfig(guildId string) ServerConfig { sc := ServerConfig{} + // If this fails, we'll return a default server + // config, which is expected bot.DB.Where(&ServerConfig{DiscordId: guildId}).Find(&sc) - if (sc == ServerConfig{}) { - return defaultServerConfig - } return sc } @@ -107,7 +143,7 @@ func (bot *ArchiverBot) updateServerSetting(guildID string, setting string, // Now we get the current server config and return it sc = bot.getServerConfig(guildID) - // We only expect one server to be updated at a time. Otherwise, return an error. + // We only expect one server to be updated at a time. Otherwise, return an error if tx.RowsAffected != 1 { log.Errorf("did not expect %v rows to be affected updating "+ "server config for server: %v(%v)", fmt.Sprintf("%v", tx.RowsAffected), guild.Name, guild.ID) @@ -117,17 +153,17 @@ func (bot *ArchiverBot) updateServerSetting(guildID string, setting string, } // updateServersWatched updates the servers watched value -// in both the local bot stats and in the database. It is allowed to fail. +// in both the local bot stats and in the database. It is allowed to fail func (bot *ArchiverBot) updateServersWatched() error { - var serversWatched, serversConfigured int64 - serversWatched = int64(len(bot.DG.State.Ready.Guilds)) + var serversConfigured, serversActive int64 bot.DB.Model(&ServerRegistration{}).Where(&ServerRegistration{}).Count(&serversConfigured) - log.Debugf("total number of servers configured: %v, connected servers: %v", serversConfigured, serversWatched) + serversActive = int64(len(bot.DG.State.Ready.Guilds)) + log.Debugf("total number of servers configured: %v, connected servers: %v", serversConfigured, serversActive) updateStatusData := &discordgo.UpdateStatusData{Status: "online"} updateStatusData.Activities = make([]*discordgo.Activity, 1) updateStatusData.Activities[0] = &discordgo.Activity{ - Name: fmt.Sprintf("%v servers", serversWatched), + Name: fmt.Sprintf("%v servers", serversActive), Type: discordgo.ActivityTypeWatching, URL: archiverRepoUrl, } diff --git a/bot/stats.go b/bot/stats.go index 1039ce5..3b65849 100644 --- a/bot/stats.go +++ b/bot/stats.go @@ -1,9 +1,12 @@ package bot import ( + "database/sql" "fmt" ) +// botStats is read by structToPrettyDiscordFields and converted +// into a slice of *discordgo.MessageEmbedField type botStats struct { ArchiveRequests int64 `pretty:"Times the bot has been called"` MessagesSent int64 `pretty:"Messages Sent"` @@ -11,32 +14,37 @@ type botStats struct { URLsArchived int64 `pretty:"URLs Archived"` Interactions int64 `pretty:"Interactions with the bot"` TopDomains string `pretty:"Top 5 Domains" inline:"false"` - ServersWatched int64 `pretty:"Servers Watched"` + ServersActive int64 `pretty:"Active servers"` + ServersConfigured int64 `pretty:"Configured servers" global:"true"` } -type domainStats struct { +// domainStats is a slice of simple objects that specify a domain name +// and a count, for use in stats commands to determine most +// popular domains +type domainStats []struct { RequestDomainName string Count int } -// getGlobalStats calls the database to get global stats for the bot. +// getGlobalStats calls the database to get global stats for the bot // The output here is not appropriate to send to individual servers, except -// for ServersWatched. +// for ServersActive func (bot *ArchiverBot) getGlobalStats() botStats { - var ArchiveRequests, MessagesSent, CallsToArchiveOrg, Interactions, ServersWatched int64 + var ArchiveRequests, MessagesSent, Interactions, CallsToArchiveOrg, URLsArchived, ServersConfigured, ServersActive int64 serverId := bot.DG.State.User.ID botId := bot.DG.State.User.ID - archiveRows := []ArchiveEventEvent{} - var topDomains []domainStats + var topDomains domainStats bot.DB.Model(&MessageEvent{}).Not(&MessageEvent{AuthorId: botId}).Count(&ArchiveRequests) bot.DB.Model(&MessageEvent{}).Where(&MessageEvent{AuthorId: serverId}).Count(&MessagesSent) bot.DB.Model(&InteractionEvent{}).Where(&InteractionEvent{}).Count(&Interactions) bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{Cached: false}).Count(&CallsToArchiveOrg) - bot.DB.Model(&ArchiveEvent{}).Scan(&archiveRows) + bot.DB.Model(&ArchiveEvent{}).Count(&URLsArchived) bot.DB.Model(&ArchiveEvent{}).Select("request_domain_name, count(request_domain_name) as count"). Group("request_domain_name").Order("count DESC").Find(&topDomains) - bot.DB.Model(&ServerRegistration{}).Where(&ServerRegistration{}).Count(&ServersWatched) + bot.DB.Model(&ServerRegistration{}).Count(&ServersConfigured) + bot.DB.Find(&ServerRegistration{}).Where(&ServerRegistration{ + Active: ConfigBool{sql.NullBool{Bool: true}}}).Count(&ServersActive) var topDomainsFormatted string for i := 0; i < 5 && i < len(topDomains); i++ { @@ -52,31 +60,31 @@ func (bot *ArchiverBot) getGlobalStats() botStats { ArchiveRequests: ArchiveRequests, MessagesSent: MessagesSent, CallsToArchiveOrg: CallsToArchiveOrg, - URLsArchived: int64(len(archiveRows)), + URLsArchived: URLsArchived, Interactions: Interactions, TopDomains: topDomainsFormatted, - ServersWatched: ServersWatched, + ServersConfigured: ServersConfigured, + ServersActive: ServersActive, } } -// getServerStats gets the stats for a particular server with ID serverId. +// getServerStats gets the stats for a particular server with ID serverId // If you want global stats, use getGlobalStats() func (bot *ArchiverBot) getServerStats(serverId string) botStats { - var ArchiveRequests, MessagesSent, CallsToArchiveOrg, Interactions, ServersWatched int64 + var ArchiveRequests, MessagesSent, CallsToArchiveOrg, URLsArchived, Interactions, ServersActive int64 botId := bot.DG.State.User.ID - archiveRows := []ArchiveEventEvent{} - var topDomains []domainStats + var topDomains domainStats bot.DB.Model(&MessageEvent{}).Where(&MessageEvent{ServerID: serverId}). Not(&MessageEvent{AuthorId: botId}).Count(&ArchiveRequests) bot.DB.Model(&MessageEvent{}).Where(&MessageEvent{ServerID: serverId, AuthorId: botId}).Count(&MessagesSent) bot.DB.Model(&InteractionEvent{}).Where(&InteractionEvent{ServerID: serverId}).Count(&Interactions) bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{ServerID: serverId, Cached: false}).Count(&CallsToArchiveOrg) - bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{ServerID: serverId}).Scan(&archiveRows) + bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{ServerID: serverId}).Count(&ArchiveRequests) bot.DB.Model(&ArchiveEvent{}).Where(&ArchiveEvent{ServerID: serverId}). Select("request_domain_name, count(request_domain_name) as count").Order("count DESC"). Group("request_domain_name").Find(&topDomains) - bot.DB.Model(&ServerRegistration{}).Where(&ServerRegistration{}).Count(&ServersWatched) + bot.DB.Model(&ServerRegistration{}).Where(&ServerRegistration{}).Count(&ServersActive) var topDomainsFormatted string for i := 0; i < 5 && i < len(topDomains); i++ { @@ -92,9 +100,9 @@ func (bot *ArchiverBot) getServerStats(serverId string) botStats { ArchiveRequests: ArchiveRequests, MessagesSent: MessagesSent, CallsToArchiveOrg: CallsToArchiveOrg, - URLsArchived: int64(len(archiveRows)), + URLsArchived: URLsArchived, Interactions: Interactions, TopDomains: topDomainsFormatted, - ServersWatched: ServersWatched, + ServersActive: ServersActive, } } diff --git a/bot/util.go b/bot/util.go index 3f221d9..6614d57 100644 --- a/bot/util.go +++ b/bot/util.go @@ -17,11 +17,11 @@ import ( // and returns an array of the field names. Ripped from // https://stackoverflow.com/a/18927729 func convertFlatStructToSliceStringMap(i interface{}) []map[string]string { - // Get reflections t := reflect.TypeOf(i) tv := reflect.ValueOf(i) - // Keys is a list of keys of the values map. It's used for sorting later + // Keys is a list of keys of the values map + // It's used for alphanumeric sorting later keys := make([]string, 0, t.NumField()) // Values is an object that will hold an unsorted representation @@ -37,8 +37,6 @@ func convertFlatStructToSliceStringMap(i interface{}) []map[string]string { } sort.Strings(keys) - - // Now we will sort the values returned above into a sortedValues sortedValues := make([]map[string]string, 0, t.NumField()) for _, k := range keys { sortedValues = append(sortedValues, map[string]string{k: values[k]}) @@ -47,8 +45,8 @@ func convertFlatStructToSliceStringMap(i interface{}) []map[string]string { return sortedValues } -// getTagValue looks up the tag for a given field of the specified type. -// Be advised, if the tag can't be found, it returns an empty string. +// getTagValue looks up the tag for a given field of the specified type +// Be advised, if the tag can't be found, it returns an empty string func getTagValue(i interface{}, field string, tag string) string { r, ok := reflect.TypeOf(i).FieldByName(field) if !ok { @@ -57,14 +55,20 @@ func getTagValue(i interface{}, field string, tag string) string { return r.Tag.Get(tag) } -// Returns a multiline string that pretty prints botStats. -func structToPrettyDiscordFields(i any) []*discordgo.MessageEmbedField { +// Returns a multiline string that pretty prints botStats +func structToPrettyDiscordFields(i any, globalMessage bool) []*discordgo.MessageEmbedField { var fields ([]*discordgo.MessageEmbedField) stringMapSlice := convertFlatStructToSliceStringMap(i) for _, stringMap := range stringMapSlice { for key, value := range stringMap { + globalKey := getTagValue(i, key, "global") == "true" + // If this key is a global key but + // the message is not a global message, skip adding the field + if globalKey && !globalMessage { + continue + } formattedKey := getTagValue(i, key, "pretty") newField := discordgo.MessageEmbedField{ Name: formattedKey, @@ -83,7 +87,7 @@ func retryOptions(sc ServerConfig) (options []discordgo.SelectMenuOption) { for i := globals.MinAllowedRetryAttempts; i <= globals.MaxAllowedRetryAttempts; i++ { description := "" - if uint(i) == sc.RetryAttempts { + if sc.RetryAttempts.Valid && int32(i) == sc.RetryAttempts.Int32 { description = "Current value" } @@ -101,7 +105,7 @@ func retryRemoveOptions(sc ServerConfig) (options []discordgo.SelectMenuOption) for _, value := range globals.AllowedRetryAttemptRemovalDelayValues { description := "" - if uint(value) == sc.RemoveRetriesDelay { + if sc.RemoveRetriesDelay.Valid && int32(value) == sc.RemoveRetriesDelay.Int32 { description = "Current value" } @@ -115,7 +119,7 @@ func retryRemoveOptions(sc ServerConfig) (options []discordgo.SelectMenuOption) } // typeInChannel sets the typing indicator for a channel. The indicator is cleared -// when a message is sent. +// when a message is sent func (bot *ArchiverBot) typeInChannel(channel chan bool, channelID string) { for { select { @@ -139,11 +143,11 @@ func (bot *ArchiverBot) SettingsIntegrationResponse(sc ServerConfig) *discordgo. Components: []discordgo.MessageComponent{ discordgo.Button{ Label: getTagValue(sc, "ArchiveEnabled", "pretty"), - Style: globals.ButtonStyle[sc.ArchiveEnabled], + Style: globals.ButtonStyle[sc.ArchiveEnabled.Valid && sc.ArchiveEnabled.Bool], CustomID: globals.BotEnabled}, discordgo.Button{ Label: getTagValue(sc, "ShowDetails", "pretty"), - Style: globals.ButtonStyle[sc.ShowDetails], + Style: globals.ButtonStyle[sc.ShowDetails.Valid && sc.ShowDetails.Bool], CustomID: globals.Details}, }, }, @@ -151,11 +155,11 @@ func (bot *ArchiverBot) SettingsIntegrationResponse(sc ServerConfig) *discordgo. Components: []discordgo.MessageComponent{ discordgo.Button{ Label: getTagValue(sc, "AlwaysArchiveFirst", "pretty"), - Style: globals.ButtonStyle[sc.AlwaysArchiveFirst], + Style: globals.ButtonStyle[sc.AlwaysArchiveFirst.Valid && sc.AlwaysArchiveFirst.Bool], CustomID: globals.AlwaysArchiveFirst}, discordgo.Button{ - Label: getTagValue(sc, "RemoveRetries", "pretty"), - Style: globals.ButtonStyle[sc.AlwaysArchiveFirst], + Label: getTagValue(sc, "RemoveRetry", "pretty"), + Style: globals.ButtonStyle[sc.RemoveRetry.Valid && sc.RemoveRetry.Bool], CustomID: globals.RemoveRetry}, }, }, @@ -182,7 +186,7 @@ func (bot *ArchiverBot) SettingsIntegrationResponse(sc ServerConfig) *discordgo. } // settingsFailureIntegrationResponse returns a *discordgo.InteractionResponseData -// stating that a failure to update settings has occured. +// stating that a failure to update settings has occured func (bot *ArchiverBot) settingsFailureIntegrationResponse() *discordgo.InteractionResponseData { return &discordgo.InteractionResponseData{ Flags: uint64(discordgo.MessageFlagsEphemeral), @@ -196,7 +200,7 @@ func (bot *ArchiverBot) settingsFailureIntegrationResponse() *discordgo.Interact } // settingsFailureIntegrationResponse returns a *discordgo.InteractionResponseData -// stating that a failure to update settings has occured. +// stating that a failure to update settings has occured func (bot *ArchiverBot) settingsDMFailureIntegrationResponse() *discordgo.InteractionResponseData { return &discordgo.InteractionResponseData{ Flags: uint64(discordgo.MessageFlagsEphemeral), @@ -209,7 +213,7 @@ func (bot *ArchiverBot) settingsDMFailureIntegrationResponse() *discordgo.Intera } } -// deleteAllCommands is referenced in bot.go +// deleteAllCommands is referenced in bot.go (but is probably commented out) func (bot *ArchiverBot) deleteAllCommands() { globalCommands, err := bot.DG.ApplicationCommands(bot.DG.State.User.ID, "") if err != nil { diff --git a/globals/globals.go b/globals/globals.go index df89321..7062a0f 100644 --- a/globals/globals.go +++ b/globals/globals.go @@ -11,7 +11,7 @@ const ( Settings = "settings" Help = "help" - // Bot settings + // Bot settings unique handler names // Booleans BotEnabled = "enabled" AlwaysArchiveFirst = "alwayssnapshotfirst" @@ -27,7 +27,7 @@ const ( // Archive.org URL timestamp layout ArchiveOrgTimestampLayout = "20060102150405" - // Help text + // Shown to the user when `/help` is called BotHelpText = `**Usage** React to a message that has links with 🏛 (The "classical building" emoji) and the bot will respond in the channel with an archive.org link for the link(s). It saves the page to archive.org if needed. @@ -52,8 +52,8 @@ var ( MaxAllowedRetryAttemptsFloat = float64(MaxAllowedRetryAttempts) AllowedRetryAttemptRemovalDelayValues = []int{10, 30, 90, 120, 300} - // Verb takes a boolean and returns "enabled" or "disabled" - Verb = map[bool]string{ + // Enabled takes a boolean and returns "enabled" or "disabled" + Enabled = map[bool]string{ true: "enabled", false: "disabled", } diff --git a/main.go b/main.go index dc6b720..aca337a 100644 --- a/main.go +++ b/main.go @@ -107,7 +107,7 @@ func main() { log.Info("using ", dbType, " for the database") - // Create a new Discord session using the provided bot token. + // Create a new Discord session using the provided bot token dg, err := discordgo.New("Bot " + config.Token) if err != nil { log.Fatal("error creating Discord session: ", err) @@ -115,7 +115,7 @@ func main() { // ArchiverBot is an instance of this bot. It has many methods attached to // it for controlling the bot. db is the database object, dg is the - // discordgo object. + // discordgo object archiveBot := bot.ArchiverBot{ DB: db, DG: dg, @@ -135,7 +135,7 @@ func main() { go archiveBot.StartHealthAPI() // These handlers get called whenever there's a corresponding - // Discord event. + // Discord event dg.AddHandler(archiveBot.BotReadyHandler) dg.AddHandler(archiveBot.GuildCreateHandler) dg.AddHandler(archiveBot.MessageReactionAddHandler) @@ -143,21 +143,21 @@ func main() { // We have to be explicit about what we want to receive. In addition, // some intents require additional permissions, which must be granted - // to the bot when it's added or after the fact by a guild admin. + // to the bot when it's added or after the fact by a guild admin discordIntents := discordgo.IntentsGuildMessages | discordgo.IntentsGuilds | discordgo.IntentsDirectMessages | discordgo.IntentsDirectMessageReactions | discordgo.IntentsGuildMessageReactions dg.Identify.Intents = discordIntents - // Open a websocket connection to Discord and begin listening. + // Open a websocket connection to Discord and begin listening if err := dg.Open(); err != nil { log.Fatal("error opening connection to discord: ", err) } - // Wait here until CTRL-C or other term signal is received. + // Wait here until CTRL-C or other term signal is received log.Info("bot started") - // Cleanly close down the Discord session. + // Cleanly close down the Discord session defer dg.Close() // Listen for signals from the OS