From 44dccb5098c71d7dacf20d102635032e2c379bd9 Mon Sep 17 00:00:00 2001 From: AdamH18 Date: Sat, 28 Oct 2023 20:01:48 -0400 Subject: [PATCH] Autocomplete and series billboards --- README.md | 1 + bot/billboards.go | 79 ++++++++++++++-- bot/buildtables.go | 61 ++++++++++--- bot/commands.go | 173 +++++++++++++++++++++++------------ bot/componenthandlers.go | 135 +++++++++++++++++++++++++++ bot/handlers.go | 192 +++++++++++++++++++++++++++++++-------- bot/utils.go | 99 ++++++++++++++++++++ database/datastructs.go | 13 +++ database/dbrepo.go | 175 ++++++++++++++++++++++++++++++++++- database/execwrappers.go | 1 + database/tables.go | 5 +- 11 files changed, 814 insertions(+), 120 deletions(-) create mode 100644 bot/componenthandlers.go diff --git a/README.md b/README.md index 198c1a9..b6160bc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ docker compose up -d | /remove_series_channel | admin only | Deregister a channel with a given series, channel is not deleted | | /add_user | admin only | Register a user as a member of the group | | /remove_user | admin only | Remove a user from the group, deletes all related settings. User is not kicked | +| /remove_user_by_id | admin only | Remove a user from the group by manually input ID, deletes all related settings. User is not kicked | | /server_users | admin only | See all registered users on the server | | /add_job | admin only | Register a new job type for the group | | /add_global_job | owner only | Register a new job type for all users | diff --git a/bot/billboards.go b/bot/billboards.go index 98c35d9..f456f30 100644 --- a/bot/billboards.go +++ b/bot/billboards.go @@ -1,6 +1,7 @@ package bot import ( + "fmt" "log" "scanlation-discord-bot/database" @@ -8,11 +9,79 @@ import ( ) func UpdateSeriesBillboard(series string, guild string) { + log.Printf("Updating series billboard for series %s and guild %s\n", series, guild) + bill, channel, err := database.Repo.GetSeriesBillboard(series, guild) + if err != nil { + log.Println("Error getting billboard message: " + err.Error()) + return + } else if bill == "" { + log.Println("Server does not have a billboard for this series") + return + } + //Billboard should be edited, so gather data + serData, err := database.Repo.GetAllSeriesInfo(series, guild) + if err != nil { + log.Println("Error getting series info for billboard: " + err.Error()) + return + } + notes, _, err := database.Repo.GetSeriesNotes(series, guild) + if err != nil { + log.Println("Error getting notes info for billboard: " + err.Error()) + notes = []string{} + } + assMap, err := database.Repo.GetSeriesAssignments(series, guild) + if err != nil { + log.Println("Error getting series assignment info: " + err.Error()) + return + } + + //Build embeds + serInfoEmb := BuildSeriesInfoEmbed(serData, notes) + serAssEmb, err := BuildSeriesAssignmentsEmbed(assMap, series, guild) + if err != nil { + log.Println("Error building assignments embed: " + err.Error()) + return + } + + message := discordgo.MessageEdit{ + Content: &emptyStr, + Embeds: []*discordgo.MessageEmbed{serInfoEmb, serAssEmb}, + ID: bill, + Channel: channel, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Add Note", + Style: discordgo.PrimaryButton, + CustomID: fmt.Sprintf("note_add_button %s", series), + }, + discordgo.Button{ + Label: "Remove Note", + Style: discordgo.DangerButton, + CustomID: fmt.Sprintf("note_remove_button %s", series), + }, + }, + }, + }, + } + _, err = goBot.ChannelMessageEditComplex(&message) + if err != nil { + log.Println("Error editing message: " + err.Error()) + } } func UpdateAllSeriesBillboards(guild string) { - + log.Printf("Updating all series billboards in guild %s\n", guild) + allBill, err := database.Repo.GetAllSeriesBillboards(guild) + if err != nil { + log.Println("Error getting all billboards: " + err.Error()) + return + } + for _, bill := range allBill { + go UpdateSeriesBillboard(bill, guild) + } } // Edit existing assignments billboard message to reflect new data @@ -41,6 +110,7 @@ func UpdateAssignmentsBillboard(guild string) { } message := discordgo.MessageEdit{ + Content: &emptyStr, Embeds: []*discordgo.MessageEmbed{embed}, ID: bill, Channel: channel, @@ -70,13 +140,10 @@ func UpdateColorsBillboard(guild string) { return } - embed, err := BuildColorsEmbed(assMap, guild) - if err != nil { - log.Println("Error building embed: " + err.Error()) - return - } + embed := BuildColorsEmbed(assMap, guild) message := discordgo.MessageEdit{ + Content: &emptyStr, Embeds: []*discordgo.MessageEmbed{embed}, ID: bill, Channel: channel, diff --git a/bot/buildtables.go b/bot/buildtables.go index ac52332..fe9e4b8 100644 --- a/bot/buildtables.go +++ b/bot/buildtables.go @@ -11,22 +11,21 @@ import ( "github.com/bwmarrin/discordgo" ) -// TODO: Fix tables to not error on user retrieval errors, just don't list that user - // Returns table with all values in reminders DB included -func BuildVerboseRemindersTable(rems []database.Reminder) (string, error) { +func BuildVerboseRemindersTable(rems []database.Reminder) string { var buf strings.Builder w := tabwriter.NewWriter(&buf, 1, 1, 1, ' ', 0) fmt.Fprintln(w, "Reminders for all users:\n\nID\tGuild\tChannel\tUser\tDays\tMessage\tRepeat\tTime\t") for _, rem := range rems { usr, err := GetUserName(rem.Guild, rem.User) if err != nil { - return "", err + log.Printf("Error retrieving username for user %s from guild %s: %s\n", rem.User, rem.Guild, err.Error()) + usr = "" } fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%t\t%s\t\n", rem.ID, rem.Guild, rem.Channel, usr, rem.Days, rem.Message, rem.Repeat, rem.Time) } w.Flush() - return "```\n" + buf.String() + "```", nil + return "```\n" + buf.String() + "```" } // Returns table with useful values in reminders DB included @@ -132,7 +131,9 @@ func BuildSeriesAssignmentsEmbed(assMap map[string][]string, series string, guil for _, user := range assMap[job] { name, err := GetUserPing(guild, user) if err != nil { - return nil, err + //Can occur if user leaves server without being removed from DB + log.Printf("Error retrieving user ping for user %s from guild %s: %s\n", user, guild, err.Error()) + name = user } usersStr += name color, err := database.Repo.GetUserColor(user, guild) @@ -218,7 +219,8 @@ func BuildJobAssignmentsEmbed(assMap map[string][]string, job string, guild stri userF := new(discordgo.MessageEmbedField) userF.Name, err = GetUserName(guild, user) if err != nil { - return nil, err + log.Printf("Error retrieving username for user %s from guild %s: %s\n", user, guild, err.Error()) + userF.Name = user } //Build string for each set of series seriesStr := "" @@ -237,6 +239,38 @@ func BuildJobAssignmentsEmbed(assMap map[string][]string, job string, guild stri return &embed, nil } +// Builds embed for basic series info plus notes +func BuildSeriesInfoEmbed(series database.Series, notes []string) *discordgo.MessageEmbed { + //Initialize embed + embed := discordgo.MessageEmbed{ + Type: discordgo.EmbedTypeRich, + Title: series.NameFull, + Description: "Alias for commands - " + series.NameSh, + } + fields := []*discordgo.MessageEmbedField{} + + //Add repo link field + if series.RepoLink != "" { + fields = append(fields, &discordgo.MessageEmbedField{Name: "Repo Link:", Value: series.RepoLink}) + } + + //Add notes field + if len(notes) != 0 { + ind := 1 + text := "" + for _, n := range notes { + text += fmt.Sprintf("%d. %s\n", ind, n) + ind++ + } + text = text[:len(text)-1] + fields = append(fields, &discordgo.MessageEmbedField{Name: "Series Notes:", Value: text}) + } + + embed.Fields = fields + + return &embed +} + // Builds the embed for showing all assignments. Hierarchy is series-job-user func BuildFullAssignmentsEmbed(assMap map[string]map[string][]string, guild string) (*discordgo.MessageEmbed, error) { //Initialize embed @@ -289,7 +323,8 @@ func BuildFullAssignmentsEmbed(assMap map[string]map[string][]string, guild stri for _, user := range assMap[ser][job] { userN, err := GetUserPing(guild, user) if err != nil { - return nil, err + log.Printf("Error retrieving user ping for user %s from guild %s: %s\n", user, guild, err.Error()) + userN = user } jobStr += userN + ", " } @@ -305,7 +340,7 @@ func BuildFullAssignmentsEmbed(assMap map[string]map[string][]string, guild stri } // Builds embed for showing user color prefs -func BuildColorsEmbed(assMap map[string]string, guild string) (*discordgo.MessageEmbed, error) { +func BuildColorsEmbed(assMap map[string]string, guild string) *discordgo.MessageEmbed { //Initialize embed embed := discordgo.MessageEmbed{ Type: discordgo.EmbedTypeRich, @@ -331,7 +366,8 @@ func BuildColorsEmbed(assMap map[string]string, guild string) (*discordgo.Messag for key := range assMap { nm, err := GetUserName(guild, key) if err != nil { - return nil, err + log.Printf("Error retrieving username for user %s from guild %s: %s\n", key, guild, err.Error()) + nm = key } namesToId[nm] = key names = append(names, nm) @@ -343,7 +379,8 @@ func BuildColorsEmbed(assMap map[string]string, guild string) (*discordgo.Messag for _, name := range names { ping, err := GetUserPing(guild, namesToId[name]) if err != nil { - return nil, err + log.Printf("Error retrieving user ping for user %s from guild %s: %s\n", name, guild, err.Error()) + ping = name } text += ping + " - " + assMap[namesToId[name]] + "\n" } @@ -351,7 +388,7 @@ func BuildColorsEmbed(assMap map[string]string, guild string) (*discordgo.Messag fields[0].Name = "Colors:" fields[0].Value = text embed.Fields = fields - return &embed, nil + return &embed } // Builds the embed for showing all server series diff --git a/bot/commands.go b/bot/commands.go index 43b8ebf..724a49c 100644 --- a/bot/commands.go +++ b/bot/commands.go @@ -2,11 +2,17 @@ package bot import "github.com/bwmarrin/discordgo" +type AutoComplete struct { + Loc int + Choices func(string, string) []*discordgo.ApplicationCommandOptionChoice +} + var ( adminPerms int64 = discordgo.PermissionAdministrator dmPerms = false daysMin = 0.0 hourModMin = -12.0 + emptyStr = "" //Definitions for all slash commands and their expected parameters commands = []*discordgo.ApplicationCommand{ @@ -250,16 +256,18 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "full-name", - Description: "Full name for the series", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "full-name", + Description: "Full name for the series", + Required: true, + Autocomplete: true, }, { - Type: discordgo.ApplicationCommandOptionString, - Name: "short-name", - Description: "Shorthand name for the series (just to make sure)", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "short-name", + Description: "Shorthand name for the series (just to make sure)", + Required: true, + Autocomplete: true, }, }, }, @@ -276,10 +284,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "short-name", - Description: "Shorthand name for the series", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "short-name", + Description: "Shorthand name for the series", + Required: true, + Autocomplete: true, }, { Type: discordgo.ApplicationCommandOptionString, @@ -296,10 +305,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "short-name", - Description: "Shorthand name for the series", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "short-name", + Description: "Shorthand name for the series", + Required: true, + Autocomplete: true, }, { Type: discordgo.ApplicationCommandOptionString, @@ -316,10 +326,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Shorthand for series to add to", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Shorthand for series to add to", + Required: true, + Autocomplete: true, }, }, }, @@ -357,6 +368,20 @@ var ( }, }, }, + { + Name: "remove_user_by_id", + Description: "Remove a user by ID (if left server), deletes all related settings. User is not kicked (admin only)", + DMPermission: &dmPerms, + DefaultMemberPermissions: &adminPerms, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "user_id", + Description: "The id of user to be removed", + Required: true, + }, + }, + }, { Name: "server_users", Description: "See all registered users on the server (admin only)", @@ -410,10 +435,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "name-short", - Description: "Shorthand for the job", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "name-short", + Description: "Shorthand for the job", + Required: true, + Autocomplete: true, }, }, }, @@ -478,16 +504,18 @@ var ( Required: true, }, { - Type: discordgo.ApplicationCommandOptionString, - Name: "job", - Description: "Shorthand name for the job", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "job", + Description: "Shorthand name for the job", + Required: true, + Autocomplete: true, }, { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Shorthand name for the series", - Required: false, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Shorthand name for the series", + Required: false, + Autocomplete: true, }, }, }, @@ -504,16 +532,18 @@ var ( Required: true, }, { - Type: discordgo.ApplicationCommandOptionString, - Name: "job", - Description: "Shorthand name for the job", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "job", + Description: "Shorthand name for the job", + Required: true, + Autocomplete: true, }, { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Shorthand name for the series", - Required: false, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Shorthand name for the series", + Required: false, + Autocomplete: true, }, }, }, @@ -537,10 +567,11 @@ var ( DMPermission: &dmPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Series shorthand if non-contextual", - Required: false, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Series shorthand if non-contextual", + Required: false, + Autocomplete: true, }, }, }, @@ -568,10 +599,11 @@ var ( DMPermission: &dmPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "job", - Description: "Job to check assignments for (shorthand)", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "job", + Description: "Job to check assignments for (shorthand)", + Required: true, + Autocomplete: true, }, }, }, @@ -720,10 +752,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Shorthand name for the series", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Shorthand name for the series", + Required: true, + Autocomplete: true, }, }, }, @@ -734,10 +767,11 @@ var ( DefaultMemberPermissions: &adminPerms, Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "series", - Description: "Shorthand name for the series", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "series", + Description: "Shorthand name for the series", + Required: true, + Autocomplete: true, }, }, }, @@ -800,7 +834,6 @@ var ( "help": HelpHandler, //"admin_help": AdminHelpHandler, - //TODO: Investigate autocomplete - https://github.com/bwmarrin/discordgo/blob/master/examples/autocomplete/main.go //TODO: Organize by subcommands "add_any_reminder": AddAnyReminderHandler, @@ -822,6 +855,7 @@ var ( "remove_series_channel": RemoveSeriesChannelHandler, "add_user": AddUserHandler, "remove_user": RemoveUserHandler, + "remove_user_by_id": RemoveUserByIDHandler, "server_users": ServerUsersHandler, "add_job": AddJobHandler, "add_global_job": AddGlobalJobHandler, @@ -851,8 +885,8 @@ var ( "vanity_role": VanityRoleHandler, "rem_vanity_role": RemVanityRoleHandler, - //"create_series_billboard": CreateSeriesBillboardHandler, - //"delete_series_billboard": DeleteSeriesBillboardHandler, + "create_series_billboard": CreateSeriesBillboardHandler, + "delete_series_billboard": DeleteSeriesBillboardHandler, "create_assignments_billboard": CreateAssignmentsBillboardHandler, "delete_assignments_billboard": DeleteAssignmentsBillboardHandler, "create_colors_billboard": CreateColorsBillboardHandler, @@ -862,4 +896,25 @@ var ( "add_notification_channel": AddNotificationChannelHandler, "send_notification": SendNotificationHandler, } + + completeHandlers = map[string][]AutoComplete{ + "remove_series": {AutoComplete{Loc: 0, Choices: SeriesFullNameAutocomplete}, AutoComplete{Loc: 1, Choices: SeriesShortNameAutocomplete}}, + "change_series_title": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + "change_series_repo": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + "add_series_channel": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + "remove_job": {AutoComplete{Loc: 0, Choices: JobShortNameAutocompleteNonG}}, + "add_series_assignment": {AutoComplete{Loc: 1, Choices: JobShortNameAutocomplete}, AutoComplete{Loc: 2, Choices: SeriesShortNameAutocomplete}}, + "remove_series_assignment": {AutoComplete{Loc: 1, Choices: JobShortNameAutocomplete}, AutoComplete{Loc: 2, Choices: SeriesShortNameAutocomplete}}, + "series_assignments": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + "job_assignments": {AutoComplete{Loc: 0, Choices: JobShortNameAutocomplete}}, + "create_series_billboard": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + "delete_series_billboard": {AutoComplete{Loc: 0, Choices: SeriesShortNameAutocomplete}}, + } + + componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate, identifier string){ + "note_add_button": NoteAddButtonHandler, + "note_add_modal": NoteAddModalHandler, + "note_remove_button": NoteRemoveButtonHandler, + "note_remove_modal": NoteRemoveModalHandler, + } ) diff --git a/bot/componenthandlers.go b/bot/componenthandlers.go new file mode 100644 index 0000000..0ada1e1 --- /dev/null +++ b/bot/componenthandlers.go @@ -0,0 +1,135 @@ +package bot + +import ( + "log" + "scanlation-discord-bot/database" + "strconv" + + "github.com/bwmarrin/discordgo" +) + +// Handler for note_add_button +func NoteAddButtonHandler(s *discordgo.Session, i *discordgo.InteractionCreate, identifier string) { + LogCommand(i, "note_add_button") + + if !database.Repo.RegisteredUser(i.Member.User.ID, i.GuildID) { + Respond(s, i, "You are not registered with this group, please get registered before adding notes.") + return + } + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "note_add_modal " + identifier, + Title: "Add Note", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "note", + Label: "Add Note", + Style: discordgo.TextInputParagraph, + Placeholder: "Write your note here", + Required: true, + }, + }, + }, + }, + }, + }) + if err != nil { + log.Printf("Add note modal interaction failed for guild %s and identifier %s: %s\n", i.GuildID, identifier, err.Error()) + } +} + +// Handler for note_add_modal +func NoteAddModalHandler(s *discordgo.Session, i *discordgo.InteractionCreate, identifier string) { + LogCommand(i, "note_add_modal") + + data := i.ModalSubmitData() + note := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value + + serNote := database.SeriesNote{ + Series: identifier, + Note: note, + Guild: i.GuildID, + } + err := database.Repo.AddSeriesNote(serNote) + response := "" + if err != nil { + response = "Failed to add note to database. Error: " + err.Error() + } else { + response = "Note successfully added" + } + + Respond(s, i, response) +} + +// Handler for note_remove_button +func NoteRemoveButtonHandler(s *discordgo.Session, i *discordgo.InteractionCreate, identifier string) { + LogCommand(i, "note_remove_button") + + if !database.Repo.RegisteredUser(i.Member.User.ID, i.GuildID) { + Respond(s, i, "You are not registered with this group, please get registered before removing notes.") + return + } + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "note_remove_modal " + identifier, + Title: "Remove Note", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "note", + Label: "Remove Note", + Style: discordgo.TextInputShort, + Placeholder: "Input the number of the note you wish to remove here", + Required: true, + }, + }, + }, + }, + }, + }) + if err != nil { + log.Printf("Remove note modal interaction failed for guild %s and identifier %s: %s\n", i.GuildID, identifier, err.Error()) + } +} + +// Handler for note_remove_modal +func NoteRemoveModalHandler(s *discordgo.Session, i *discordgo.InteractionCreate, identifier string) { + LogCommand(i, "note_remove_modal") + + data := i.ModalSubmitData() + note := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value + + num, err := strconv.Atoi(note) + if err != nil { + Respond(s, i, "Input value could not be parsed to an integer") + return + } + + _, ids, err := database.Repo.GetSeriesNotes(identifier, i.GuildID) + if err != nil { + Respond(s, i, "Error getting notes from database: "+err.Error()) + return + } + if num < 1 || num > len(ids) { + Respond(s, i, "Provided number is outside of range of notes on this series") + return + } + done, err := database.Repo.RemoveSeriesNote(identifier, i.GuildID, ids[num-1]) + response := "" + if err != nil { + response = "Failed to remove note from database. Error: " + err.Error() + } else if !done { + response = "Note was not found in database" + } else { + response = "Note successfully removed" + } + + Respond(s, i, response) +} diff --git a/bot/handlers.go b/bot/handlers.go index b0cbc4e..35bd5e9 100644 --- a/bot/handlers.go +++ b/bot/handlers.go @@ -175,12 +175,7 @@ func AllRemindersHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { response = "Error getting reminders from database: " + err.Error() } else { //Build reminders table from results - resp, err := BuildVerboseRemindersTable(rems) - if err != nil { - response = "Error creating verbose reminders table: " + err.Error() - } else { - response = resp - } + response = BuildVerboseRemindersTable(rems) } Respond(s, i, response) } @@ -500,6 +495,37 @@ func RemoveUserHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { Respond(s, i, response) } +// Handler for remove_user_by_id +func RemoveUserByIDHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + LogCommand(i, "remove_user_by_id") + options := OptionsToMap(i.ApplicationCommandData().Options) + user := options["user"].StringValue() + log.Printf("User: %s", user) + + //Removing user from DB + done, err := database.Repo.RemoveUser(user, i.GuildID) + response := "" + if err != nil { + response = "Error removing user from database: " + err.Error() + } else if !done { + response = "This user was not registered in the first place" + } else { + response = "Successfully removed user from database" + + //If member role is set, remove role from user + mem := database.Repo.GetMemberRole(i.GuildID) + if mem != "" { + err = s.GuildMemberRoleRemove(i.GuildID, user, mem) + if err != nil { + response += "\nError removing member role: " + err.Error() + } else { + response += "\nMember role successfully removed" + } + } + } + Respond(s, i, response) +} + // Handler for server_users func ServerUsersHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { LogCommand(i, "server_users") @@ -1136,36 +1162,82 @@ func RemVanityRoleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) Respond(s, i, response) } -// Handler for create_assignments_billboard -func CreateAssignmentsBillboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { - LogCommand(i, "create_assignments_billboard") +// Handler for create_series_billboard +func CreateSeriesBillboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + LogCommand(i, "create_series_billboard") + options := OptionsToMap(i.ApplicationCommandData().Options) + series := options["series"].StringValue() + log.Printf("Series: %s", series) - bill, _, err := database.Repo.GetRolesBillboard(i.GuildID) + bill, _, err := database.Repo.GetSeriesBillboard(series, i.GuildID) if err != nil { Respond(s, i, "Error getting existing billboard info: "+err.Error()) return } else if bill != "" { - Respond(s, i, "Server already has an assignments billboard. Please remove the existing one first") + Respond(s, i, "Server already has a billboard for this series. Please remove the existing one first") return } - //Billboard should be created, so gather data - assMap, err := database.Repo.GetAllAssignments(i.GuildID) + //Send a basic message to be turned into the billboard later + msg, err := s.ChannelMessageSend(i.ChannelID, "This message will become the billboard. If it doesn't something went wrong.") if err != nil { - Respond(s, i, "Error getting server assignments info: "+err.Error()) + Respond(s, i, "Error sending message: "+err.Error()) return } - embed, err := BuildFullAssignmentsEmbed(assMap, i.GuildID) + bb := database.SeriesBB{ + Series: series, + Guild: i.GuildID, + Channel: i.ChannelID, + Message: msg.ID, + } + err = database.Repo.AddSeriesBillboard(bb) + response := "" if err != nil { - Respond(s, i, "Error building embed: "+err.Error()) - return + response = "Error updating database: " + err.Error() + } else { + response = "Successfully created assignments billboard" + } + Respond(s, i, response) + + UpdateSeriesBillboard(series, i.GuildID) +} + +// Handler for delete_series_billboard +func DeleteSeriesBillboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + LogCommand(i, "delete_series_billboard") + options := OptionsToMap(i.ApplicationCommandData().Options) + series := options["series"].StringValue() + log.Printf("Series: %s", series) + + //Removing roles billboard from DB + done, err := database.Repo.RemoveSeriesBillboard(series, i.GuildID) + response := "" + if err != nil { + response = "Error removing billboard from database: " + err.Error() + } else if !done { + response = "Could not locate billboard for removal" + } else { + response = "Successfully removed billboard from database" } + Respond(s, i, response) +} + +// Handler for create_assignments_billboard +func CreateAssignmentsBillboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + LogCommand(i, "create_assignments_billboard") - message := discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{embed}, + bill, _, err := database.Repo.GetRolesBillboard(i.GuildID) + if err != nil { + Respond(s, i, "Error getting existing billboard info: "+err.Error()) + return + } else if bill != "" { + Respond(s, i, "Server already has an assignments billboard. Please remove the existing one first") + return } - msg, err := s.ChannelMessageSendComplex(i.ChannelID, &message) + + //Send a basic message to be turned into the billboard later + msg, err := s.ChannelMessageSend(i.ChannelID, "This message will become the billboard. If it doesn't something went wrong.") if err != nil { Respond(s, i, "Error sending message: "+err.Error()) return @@ -1184,6 +1256,8 @@ func CreateAssignmentsBillboardHandler(s *discordgo.Session, i *discordgo.Intera response = "Successfully created assignments billboard" } Respond(s, i, response) + + UpdateAssignmentsBillboard(i.GuildID) } // Handler for delete_assignments_billboard @@ -1216,23 +1290,8 @@ func CreateColorsBillboardHandler(s *discordgo.Session, i *discordgo.Interaction return } - //Billboard should be created, so gather data - assMap, err := database.Repo.GetAllColors(i.GuildID) - if err != nil { - Respond(s, i, "Error getting user color info: "+err.Error()) - return - } - - embed, err := BuildColorsEmbed(assMap, i.GuildID) - if err != nil { - Respond(s, i, "Error building embed: "+err.Error()) - return - } - - message := discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{embed}, - } - msg, err := s.ChannelMessageSendComplex(i.ChannelID, &message) + //Send a basic message to be turned into the billboard later + msg, err := s.ChannelMessageSend(i.ChannelID, "This message will become the billboard. If it doesn't something went wrong.") if err != nil { Respond(s, i, "Error sending message: "+err.Error()) return @@ -1251,6 +1310,8 @@ func CreateColorsBillboardHandler(s *discordgo.Session, i *discordgo.Interaction response = "Successfully created colors billboard" } Respond(s, i, response) + + UpdateColorsBillboard(i.GuildID) } // Handler for delete_colors_billboard @@ -1354,11 +1415,62 @@ func SendNotificationHandler(s *discordgo.Session, i *discordgo.InteractionCreat Respond(s, i, fmt.Sprintf("%d messages sent successfully\n%d messages failed to send", good, bad)) } -// Creates handlers for all slash commands based on relationship defined in commandHandlers +// Send autocomplete choices based on functions and locations indicated in completes var +func AutoCompleteHandler(s *discordgo.Session, i *discordgo.InteractionCreate, completes []AutoComplete) { + data := i.ApplicationCommandData() + choices := []*discordgo.ApplicationCommandOptionChoice{} + focus := -1 + for _, com := range completes { + //Identify focused autocomplete parameter and get choices for it + if data.Options[com.Loc].Focused { + choices = com.Choices(data.Options[com.Loc].StringValue(), i.GuildID) + focus = com.Loc + break + } + } + + //Respond with choices, if error send reasonably detailed log + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }) + if err != nil { + log.Printf("Error sending autocomplete for command %s with focus %d to guild %s: %s", i.ApplicationCommandData().Name, focus, i.GuildID, err.Error()) + } +} + +// Creates all handlers for the bot func CreateHandlers() { goBot.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) + switch i.Type { + //Routes handlers for all slash commands based on relationship defined in commandHandlers + case discordgo.InteractionApplicationCommand: + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + //Routes information required for autocomplete handlers + case discordgo.InteractionApplicationCommandAutocomplete: + if h, ok := completeHandlers[i.ApplicationCommandData().Name]; ok { + AutoCompleteHandler(s, i, h) + } + //Routes information for component interactions + case discordgo.InteractionMessageComponent: + loc := strings.Index(i.MessageComponentData().CustomID, " ") + func_router := i.MessageComponentData().CustomID[:loc] + identifier := i.MessageComponentData().CustomID[loc+1:] + if h, ok := componentHandlers[func_router]; ok { + h(s, i, identifier) + } + case discordgo.InteractionModalSubmit: + data := i.ModalSubmitData() + loc := strings.Index(data.CustomID, " ") + func_router := data.CustomID[:loc] + identifier := data.CustomID[loc+1:] + if h, ok := componentHandlers[func_router]; ok { + h(s, i, identifier) + } } }) } diff --git a/bot/utils.go b/bot/utils.go index 85957f9..9fc2568 100644 --- a/bot/utils.go +++ b/bot/utils.go @@ -267,3 +267,102 @@ func CreatePingRole(ser database.Series) (string, error) { } return role.ID, nil } + +// Creates choices for a series short name autocomplete +func SeriesFullNameAutocomplete(start string, guild string) []*discordgo.ApplicationCommandOptionChoice { + ser, err := database.Repo.GetAllSeries(guild) + if err != nil { + return []*discordgo.ApplicationCommandOptionChoice{} + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, s := range ser { + if strings.HasPrefix(strings.ToLower(s.NameFull), strings.ToLower(start)) { + v := discordgo.ApplicationCommandOptionChoice{ + Name: s.NameFull, + Value: s.NameFull, + } + choices = append(choices, &v) + } + } + if len(choices) > 25 { + choices = choices[:25] + } + return choices +} + +// Creates choices for a series short name autocomplete +func SeriesShortNameAutocomplete(start string, guild string) []*discordgo.ApplicationCommandOptionChoice { + ser, err := database.Repo.GetAllSeries(guild) + if err != nil { + return []*discordgo.ApplicationCommandOptionChoice{} + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, s := range ser { + if strings.HasPrefix(strings.ToLower(s.NameSh), strings.ToLower(start)) { + v := discordgo.ApplicationCommandOptionChoice{ + Name: s.NameSh, + Value: s.NameSh, + } + choices = append(choices, &v) + } + } + if len(choices) > 25 { + choices = choices[:25] + } + return choices +} + +// Creates choices for a job short name autocomplete (includes global jobs) +func JobShortNameAutocomplete(start string, guild string) []*discordgo.ApplicationCommandOptionChoice { + ser, err := database.Repo.GetAllJobs(guild) + if err != nil { + return []*discordgo.ApplicationCommandOptionChoice{} + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, s := range ser { + if strings.HasPrefix(strings.ToLower(s.JobSh), strings.ToLower(start)) { + v := discordgo.ApplicationCommandOptionChoice{ + Name: s.JobSh, + Value: s.JobSh, + } + choices = append(choices, &v) + } + } + if len(choices) > 25 { + choices = choices[:25] + } + return choices +} + +// Creates choices for a job short name autocomplete (excludes global jobs) +func JobShortNameAutocompleteNonG(start string, guild string) []*discordgo.ApplicationCommandOptionChoice { + ser, err := database.Repo.GetAllJobs(guild) + if err != nil { + return []*discordgo.ApplicationCommandOptionChoice{} + } + + serNG := []database.Job{} + for _, s := range ser { + if s.Guild != "GLOBAL" { + serNG = append(serNG, s) + } + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, s := range serNG { + if strings.HasPrefix(strings.ToLower(s.JobSh), strings.ToLower(start)) { + v := discordgo.ApplicationCommandOptionChoice{ + Name: s.JobSh, + Value: s.JobSh, + } + choices = append(choices, &v) + } + } + if len(choices) > 25 { + choices = choices[:25] + } + return choices +} diff --git a/database/datastructs.go b/database/datastructs.go index 727504d..59a8a40 100644 --- a/database/datastructs.go +++ b/database/datastructs.go @@ -63,6 +63,19 @@ type SeriesAssignment struct { Guild string } +type SeriesNote struct { + Series string + Note string + Guild string +} + +type SeriesBB struct { + Series string + Guild string + Channel string + Message string +} + type JobBB struct { Guild string Channel string diff --git a/database/dbrepo.go b/database/dbrepo.go index 6a56d5f..752d51a 100644 --- a/database/dbrepo.go +++ b/database/dbrepo.go @@ -4,6 +4,7 @@ import ( "errors" "log" "math" + "strconv" "strings" "time" ) @@ -101,6 +102,28 @@ func (r *SQLiteRepository) AddSeriesChannels(sec SeriesChannels) error { return nil } +// Add series note entry to DB +func (r *SQLiteRepository) AddSeriesNote(ser SeriesNote) error { + _, err := r.SeriesNotesExec(ser.Guild, ser.Series, "INSERT INTO series_notes(series, note, guild) values(?, ?, ?)", strings.ToLower(ser.Series), ser.Note, ser.Guild) + + if err != nil { + return err + } + + return nil +} + +// Add roles billboard entry to DB +func (r *SQLiteRepository) AddSeriesBillboard(bb SeriesBB) error { + _, err := r.RolesBillboardsExec("INSERT INTO series_billboards(series, guild, channel, message) values(?, ?, ?, ?)", bb.Series, bb.Guild, bb.Channel, bb.Message) + + if err != nil { + return err + } + + return nil +} + // Add roles billboard entry to DB func (r *SQLiteRepository) AddRolesBillboard(bb JobBB) error { _, err := r.RolesBillboardsExec("INSERT INTO roles_billboards(guild, channel, message) values(?, ?, ?)", bb.Guild, bb.Channel, bb.Message) @@ -195,7 +218,7 @@ func (r *SQLiteRepository) RemoveSeries(nameSh string, nameFull string, guildId if done { r.ChannelsExec("DELETE FROM channels WHERE series = ? AND guild = ?", nameSh, guildId) r.SeriesAssignmentsExec(guildId, "DELETE FROM series_assignments WHERE series = ? AND guild = ?", nameSh, guildId) - r.SeriesBillboardsExec("DELETE FROM series_billboard WHERE series = ? AND guild = ?", nameSh, guildId) + r.SeriesBillboardsExec("DELETE FROM series_billboards WHERE series = ? AND guild = ?", nameSh, guildId) } return done, nil @@ -294,6 +317,22 @@ func (r *SQLiteRepository) RemoveSeriesAssignment(user string, series string, jo return rows > 0, nil } +// Remove series assignment +func (r *SQLiteRepository) RemoveSeriesNote(series string, guild string, id int) (bool, error) { + res, err := r.SeriesNotesExec(guild, series, "DELETE FROM series_notes WHERE ROWID = ?", id) + + if err != nil { + return false, err + } + + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + + return rows > 0, nil +} + // Remove all assignments for a user func (r *SQLiteRepository) RemoveAllAssignments(user string, guild string) (bool, error) { res, err := r.SeriesAssignmentsExec(guild, "DELETE FROM series_assignments WHERE user = ? AND guild = ?", user, guild) @@ -310,6 +349,22 @@ func (r *SQLiteRepository) RemoveAllAssignments(user string, guild string) (bool return rows > 0, nil } +// Remove series billboard +func (r *SQLiteRepository) RemoveSeriesBillboard(series string, guild string) (bool, error) { + res, err := r.RolesBillboardsExec("DELETE FROM series_billboards WHERE series = ? AND guild = ?", series, guild) + + if err != nil { + return false, err + } + + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + + return rows > 0, nil +} + // Remove roles billboard func (r *SQLiteRepository) RemoveRolesBillboard(guild string) (bool, error) { res, err := r.RolesBillboardsExec("DELETE FROM roles_billboards WHERE guild = ?", guild) @@ -641,6 +696,58 @@ func (r *SQLiteRepository) GetSeriesAssignments(series string, guild string) (ma return assignments, nil } +// Get all notes for a given series. Order is standardized for searching reasons +func (r *SQLiteRepository) GetSeriesNotes(series string, guild string) ([]string, []int, error) { + res, err := r.db.Query("SELECT ROWID, note FROM series_notes WHERE series = ? AND guild = ?", series, guild) + + if err != nil { + return nil, nil, err + } + defer res.Close() + + //Return all notes for the series + notes := []string{} + ids := []int{} + for res.Next() { + var id, note string + if err := res.Scan(&id, ¬e); err != nil { + return nil, nil, err + } + notes = append(notes, note) + val, err := strconv.Atoi(id) + if err != nil { + return nil, nil, err + } + ids = append(ids, val) + } + + //Ensure sorted with ROWID increasing + lenN := len(notes) + sortedNotes := []string{} + sortedIds := []int{} + for i := 0; i < lenN; i++ { + //Find location of lowest ID + min := -1 + loc := -1 + for j, val := range ids { + if val < min || min == -1 { + loc = j + min = val + } + } + //Add that to end of sorted slices + sortedNotes = append(sortedNotes, notes[loc]) + sortedIds = append(sortedIds, ids[loc]) + //Replace value there with end value and chop off last value of slice + notes[loc] = notes[len(notes)-1] + notes = notes[:len(notes)-1] + ids[loc] = ids[len(ids)-1] + ids = ids[:len(ids)-1] + } + + return sortedNotes, sortedIds, nil +} + // Get all assignments for a given user func (r *SQLiteRepository) GetUserAssignments(user string, guild string) (map[string][]string, error) { res, err := r.db.Query("SELECT series, job FROM series_assignments WHERE user = ? AND guild = ?", user, guild) @@ -764,6 +871,28 @@ func (r *SQLiteRepository) GetSeriesFullName(seriesSh string, guild string) (str } } +// Get all info on series from shorthand +func (r *SQLiteRepository) GetAllSeriesInfo(seriesSh string, guild string) (Series, error) { + res, err := r.db.Query("SELECT name_full, ping_role, repo_link FROM series WHERE name_sh = ? AND guild = ?", seriesSh, guild) + + if err != nil { + return Series{}, err + } + defer res.Close() + + //If there was a result, return it + var seriesFull, pingRole, repoLink string + if res.Next() { + err = res.Scan(&seriesFull, &pingRole, &repoLink) + if err != nil { + return Series{}, err + } + return Series{NameSh: seriesSh, NameFull: seriesFull, Guild: guild, PingRole: pingRole, RepoLink: repoLink}, nil + } else { + return Series{}, errors.New("failed to get series info from DB") + } +} + // Get full name and short name of all series in server func (r *SQLiteRepository) GetAllSeries(guild string) ([]Series, error) { res, err := r.db.Query("SELECT name_sh, name_full FROM series WHERE guild = ?", guild) @@ -951,6 +1080,50 @@ func (r *SQLiteRepository) NumUsersWithVanity(role string, guild string) (int, e return count, nil } +// Get series billboard message ID in guild +func (r *SQLiteRepository) GetSeriesBillboard(series string, guild string) (string, string, error) { + res, err := r.db.Query("SELECT message, channel FROM series_billboards WHERE guild = ? AND series = ?", guild, series) + + if err != nil { + return "", "", err + } + defer res.Close() + + //If there was a result, return it + var message, channel string + if res.Next() { + err = res.Scan(&message, &channel) + if err != nil { + return "", "", err + } + return message, channel, nil + } else { + return "", "", nil + } +} + +// Get all series billboards in guild +func (r *SQLiteRepository) GetAllSeriesBillboards(guild string) ([]string, error) { + res, err := r.db.Query("SELECT series FROM series_billboards WHERE guild = ?", guild) + + if err != nil { + return nil, err + } + defer res.Close() + + //If there was a result, return it + var series string + allSer := []string{} + for res.Next() { + err = res.Scan(&series) + if err != nil { + return nil, err + } + allSer = append(allSer, series) + } + return allSer, nil +} + // Get roles billboard message ID in guild func (r *SQLiteRepository) GetRolesBillboard(guild string) (string, string, error) { res, err := r.db.Query("SELECT message, channel FROM roles_billboards WHERE guild = ?", guild) diff --git a/database/execwrappers.go b/database/execwrappers.go index ea34013..af29ce3 100644 --- a/database/execwrappers.go +++ b/database/execwrappers.go @@ -80,6 +80,7 @@ func (r *SQLiteRepository) SeriesExec(guild string, query string, args ...any) ( } } else { log.Println("Last insert value not found") + SeriesCh <- func() (string, string) { return guild, "" } return res, nil } //If single series could be identified, update billboard diff --git a/database/tables.go b/database/tables.go index 7d7f155..b99e998 100644 --- a/database/tables.go +++ b/database/tables.go @@ -84,10 +84,11 @@ var tableQuerys = []string{ `, // Message for series billboard `CREATE TABLE IF NOT EXISTS series_billboards( - series VARCHAR(100) PRIMARY KEY COLLATE NOCASE, + series VARCHAR(100) COLLATE NOCASE, guild VARCHAR(20), channel VARCHAR(30), - message VARCHAR(30) + message VARCHAR(30), + PRIMARY KEY(series, guild) ); `, // Message for roles billboard