diff --git a/bot/bot.go b/bot/bot.go index 32ab25d..43dbb7e 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -31,7 +31,7 @@ const ( type slashCommandHandler struct { ctx context.Context commands map[string]command.DiscordSlashCommandHandler - options map[string]command.DiscordEventHandler + options []command.DiscordEventHandler } func (sc slashCommandHandler) handleSlashCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -47,12 +47,16 @@ func (sc slashCommandHandler) handleSlashCommand(s *discordgo.Session, i *discor userID := i.Member.User.ID // Handle options assigned to slash commands if i.Type == discordgo.InteractionMessageComponent { - if option, ok := sc.options[i.Data.(discordgo.MessageComponentInteractionData).CustomID]; ok { - logger.InfoContext(ctx, fmt.Sprintf("user '%s' select '%s' option", userID, i.Data.(discordgo.MessageComponentInteractionData).CustomID)) + customID := i.Data.(discordgo.MessageComponentInteractionData).CustomID - handleEventResponse(ctx, s, i, option) + for _, option := range sc.options { + if matcher, ok := option.(command.DiscordOptionMatcher); ok && matcher.MatchCustomID(customID) { + logger.InfoContext(ctx, fmt.Sprintf("user '%s' select '%s' option", userID, customID)) - return + handleEventResponse(ctx, s, i, option) + + return + } } } @@ -155,15 +159,24 @@ func createCommands( } } -func createOptions(services []joke.GetService) map[string]command.DiscordEventHandler { +func createOptions( + services []joke.GetService, + commands map[string]command.DiscordSlashCommandHandler, +) []command.DiscordEventHandler { apologiesOption := command.ApologiesOption{} nextJokeOption := command.NextJokeOption{Services: services} sameJokeCategoryOption := command.SameJokeCategoryOption{Services: services} - return map[string]command.DiscordEventHandler{ - command.ApologiesButtonName: apologiesOption, - command.NextJokeButtonName: nextJokeOption, - command.SameJokeCategoryButtonName: sameJokeCategoryOption, + listCommand := commands[command.ListCommandName].(*command.ListCommand) + nextListOption := command.NextListCommandOption{Cmd: listCommand} + previousListOption := command.PreviousListCommandOption{Cmd: listCommand} + + return []command.DiscordEventHandler{ + apologiesOption, + nextJokeOption, + sameJokeCategoryOption, + nextListOption, + previousListOption, } } @@ -201,7 +214,7 @@ func NewDiscordBot(ctx context.Context) (*DiscordBot, error) { } // General handler for slash commands - handler := slashCommandHandler{ctx, commands, createOptions(getServices)} + handler := slashCommandHandler{ctx, commands, createOptions(getServices, commands)} bot.AddHandler(handler.handleSlashCommand) diff --git a/bot/internal/command/joke.go b/bot/internal/command/joke.go index cd7a14a..171860c 100644 --- a/bot/internal/command/joke.go +++ b/bot/internal/command/joke.go @@ -354,6 +354,10 @@ func createJokeFromOptions(data discordgo.ApplicationCommandInteractionData) (j type ApologiesOption struct{} +func (a ApologiesOption) MatchCustomID(customID string) bool { + return customID == ApologiesButtonName +} + func (a ApologiesOption) Execute(_ context.Context, _ *discordgo.Session, _ *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { return SimpleMessageResponse{Msg: "Przepraszam"}, nil } @@ -362,6 +366,10 @@ type NextJokeOption struct { Services []joke.GetService } +func (n NextJokeOption) MatchCustomID(customID string) bool { + return customID == NextJokeButtonName +} + func (n NextJokeOption) Execute(ctx context.Context, _ *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { service, err := selectGetService(ctx, n.Services) if err != nil { @@ -389,6 +397,10 @@ type SameJokeCategoryOption struct { Services []joke.GetService } +func (s SameJokeCategoryOption) MatchCustomID(customID string) bool { + return customID == SameJokeCategoryButtonName +} + func (s SameJokeCategoryOption) Execute(ctx context.Context, _ *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { embedFields := i.Message.Embeds[0].Fields category := joke.Category(embedFields[len(embedFields)-1].Value) diff --git a/bot/internal/command/list.go b/bot/internal/command/list.go index 03fa9a1..8849e90 100644 --- a/bot/internal/command/list.go +++ b/bot/internal/command/list.go @@ -9,18 +9,39 @@ import ( "github.com/wittano/komputer/bot/internal/voice" "log/slog" "os" + "regexp" + "strconv" "strings" ) const ( audioNameOption = "name" audioIdOption = "id" + + nextIdsButtonID = "nextIds" + previousIdsButtonID = "previousIds" ) const ListCommandName = "list" +type pageContentDirection int8 + +const ( + left pageContentDirection = -1 + none pageContentDirection = 0 + right pageContentDirection = 1 +) + +type buttonPosition uint8 + +const ( + previous buttonPosition = 0 + next buttonPosition = 1 +) + type listContentResponse struct { content []api.AudioFileInfo + pattern voice.AudioSearch } func (l listContentResponse) Response() *discordgo.InteractionResponseData { @@ -34,8 +55,10 @@ func (l listContentResponse) Response() *discordgo.InteractionResponseData { msg.WriteString(fmt.Sprintf("- %s\n", info.String())) } + const customIDFormat = "%s_%d_%s" return &discordgo.InteractionResponseData{ Title: "List of Audios IDs", + Flags: discordgo.MessageFlagsEphemeral, Embeds: []*discordgo.MessageEmbed{ { Type: discordgo.EmbedTypeRich, @@ -47,12 +70,104 @@ func (l listContentResponse) Response() *discordgo.InteractionResponseData { Description: msg.String(), }, }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Style: discordgo.SecondaryButton, + Label: "Previous", + CustomID: fmt.Sprintf(customIDFormat, previousIdsButtonID, l.pattern.Type, l.pattern.Value), + }, + discordgo.Button{ + Style: discordgo.PrimaryButton, + Label: "Next", + CustomID: fmt.Sprintf(customIDFormat, nextIdsButtonID, l.pattern.Type, l.pattern.Value), + }, + }, + }, + }, + } +} + +type NextListCommandOption struct { + Cmd *ListCommand +} + +func (n NextListCommandOption) MatchCustomID(customID string) bool { + reg := regexp.MustCompile(fmt.Sprintf("^%s_([0-1])_(a-z0-9)*", nextIdsButtonID)) + + return reg.MatchString(customID) +} + +func (n NextListCommandOption) Execute(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + } + + customerID, err := getCustomIDFromOptionIntegration(*i, next) + if err != nil { + return nil, err + } + + option, err := getOptionFromCustomID(customerID) + if err != nil { + return nil, err + } + + userID := i.Member.User.ID + result, err := n.Cmd.audioFileInfo(ctx, userID, option) + if err != nil { + return nil, err + } + + n.Cmd.updatePageCounter(len(result), userID, right) + + return listContentResponse{result, option}, nil +} + +type PreviousListCommandOption struct { + Cmd *ListCommand +} + +func (p PreviousListCommandOption) MatchCustomID(customID string) bool { + reg := regexp.MustCompile(fmt.Sprintf("^%s_([0-1])_(a-z0-9)*", previousIdsButtonID)) + + return reg.MatchString(customID) +} + +func (p PreviousListCommandOption) Execute(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + } + + customerID, err := getCustomIDFromOptionIntegration(*i, previous) + if err != nil { + return nil, err + } + + option, err := getOptionFromCustomID(customerID) + if err != nil { + return nil, err + } + + userID := i.Member.User.ID + result, err := p.Cmd.audioFileInfo(ctx, userID, option) + if err != nil { + return nil, err } + + p.Cmd.updatePageCounter(len(result), userID, left) + + return listContentResponse{result, option}, nil } type ListCommand struct { // TODO Clean up pageCounter after a few minutes - pageCounter map[string]uint + pageCounter map[string]int services []voice.AudioSearchService } @@ -67,12 +182,14 @@ func (l ListCommand) Command() *discordgo.ApplicationCommand { Name: audioNameOption, Description: "Original audio name. Suffix .mp3 isn't necessary", Required: false, + MaxLength: 80, Type: discordgo.ApplicationCommandOptionString, }, { Name: audioIdOption, Description: "Part of audio ID", Required: false, + MaxLength: 80, Type: discordgo.ApplicationCommandOptionString, }, }, @@ -80,21 +197,41 @@ func (l ListCommand) Command() *discordgo.ApplicationCommand { } func (l ListCommand) Execute(ctx context.Context, _ *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) { - option, err := getOption(i.Data.(discordgo.ApplicationCommandInteractionData)) + userID := i.Member.User.ID + option := getOption(i.Data.(discordgo.ApplicationCommandInteractionData)) + result, err := l.audioFileInfo(ctx, userID, option) if err != nil { return nil, err } - userID := i.Member.User.ID - page, _ := l.pageCounter[userID] - var result []api.AudioFileInfo + l.updatePageCounter(len(result), userID, none) + + return listContentResponse{result, option}, nil +} + +func (l *ListCommand) updatePageCounter(resultSize int, userID string, direction pageContentDirection) { + if _, ok := l.pageCounter[userID]; !ok { + l.pageCounter[userID] = 0 + } + + if resultSize == 0 || l.pageCounter[userID] < 0 { + l.pageCounter[userID] = 0 + } else if resultSize != 0 && l.pageCounter[userID] >= 0 { + l.pageCounter[userID] += int(direction) + } +} +func (l ListCommand) audioFileInfo( + ctx context.Context, + userID string, + option voice.AudioSearch, +) (result []api.AudioFileInfo, err error) { for _, service := range l.services { if !service.IsActive() { continue } - result, err = service.SearchAudio(ctx, option, page) + result, err = service.SearchAudio(ctx, option, uint(l.pageCounter[userID])) if err == nil { break } else { @@ -102,44 +239,61 @@ func (l ListCommand) Execute(ctx context.Context, _ *discordgo.Session, i *disco } } - if err != nil { - return nil, err - } - - if len(result) == 0 { - l.pageCounter[userID] = 0 - } else { - l.pageCounter[userID] += 1 - } - - return listContentResponse{result}, nil + return result, err } -func getOption(data discordgo.ApplicationCommandInteractionData) (voice.AudioSearch, error) { - for _, o := range data.Options { - var audioType voice.AudioQueryType +func getOption(data discordgo.ApplicationCommandInteractionData) (query voice.AudioSearch) { + query = voice.AudioSearch{Type: voice.IDType} + for _, o := range data.Options { switch o.Name { case audioNameOption: - audioType = voice.NameType + query.Type = voice.NameType case audioIdOption: - audioType = voice.IDType + query.Type = voice.IDType default: continue } - return voice.AudioSearch{ - Type: audioType, - Value: o.Value.(string), - }, nil + query.Value = o.Value.(string) + + return + } + + return +} + +func getOptionFromCustomID(customID string) (query voice.AudioSearch, err error) { + data := strings.Split(customID, "_")[1:] + query.Value = data[1] + + typeNum, err := strconv.Atoi(data[0]) + if err != nil { + return + } + + query.Type = voice.AudioQueryType(typeNum) + + return +} + +func getCustomIDFromOptionIntegration(i discordgo.InteractionCreate, buttonPosition buttonPosition) (string, error) { + actionRow, ok := i.Message.Components[0].(*discordgo.ActionsRow) + if !ok { + return "", errors.New("failed cast component to *discordgo.ActionsRow") + } + + button, ok := actionRow.Components[buttonPosition].(*discordgo.Button) + if !ok { + return "", errors.New("failed cast component to *discordgo.Button") } - return voice.AudioSearch{}, errors.New("unknown option") + return button.CustomID, nil } -func NewListCommand(services ...voice.AudioSearchService) ListCommand { - return ListCommand{ - make(map[string]uint), +func NewListCommand(services ...voice.AudioSearchService) *ListCommand { + return &ListCommand{ + make(map[string]int), services, } } diff --git a/bot/internal/command/types.go b/bot/internal/command/types.go index 2b7a50b..9e18f58 100644 --- a/bot/internal/command/types.go +++ b/bot/internal/command/types.go @@ -19,6 +19,10 @@ type DiscordEventHandler interface { Execute(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) (DiscordMessageReceiver, error) } +type DiscordOptionMatcher interface { + MatchCustomID(customID string) bool +} + type DiscordMessageReceiver interface { Response() *discordgo.InteractionResponseData }