diff --git a/bot/bot.go b/bot/bot.go deleted file mode 100644 index 7a97160..0000000 --- a/bot/bot.go +++ /dev/null @@ -1,67 +0,0 @@ -package bot - -import ( - "database/sql" - "errors" - "log/slog" - - "forge.capytal.company/capytal/dislate/commands" - "forge.capytal.company/capytal/dislate/db" - "forge.capytal.company/capytal/dislate/translator" - "github.com/bwmarrin/discordgo" -) - -type Bot struct { - translator translator.Translator - db *db.Queries - logger *slog.Logger - session *discordgo.Session -} - -func NewBot( - token string, - database *sql.DB, - translator translator.Translator, - log *slog.Logger, -) (*Bot, error) { - s, err := discordgo.New("Bot " + token) - if err != nil { - return nil, err - } - - s.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAllWithoutPrivileged) - - db, err := db.Prepare(database) - if err != nil { - return nil, err - } - - return &Bot{ - session: s, - db: db, - translator: translator, - logger: log, - }, nil -} - -func (b *Bot) Start() error { - if err := b.session.Open(); err != nil { - return err - } - - ch := commands.NewCommandsHandler(b.logger, b.session) - - COMMANDS := []commands.Command{ - &mockCommand{}, - } - - if err := ch.UpdateCommands(COMMANDS); err != nil { - return errors.Join(errors.New("Failed to update commands"), err) - } - - return nil -} - -func (b *Bot) Stop() error { - return b.session.Close() -} diff --git a/bot/commands.go b/bot/commands.go deleted file mode 100644 index 3a84971..0000000 --- a/bot/commands.go +++ /dev/null @@ -1,89 +0,0 @@ -package bot - -import ( - "errors" - "fmt" - - "forge.capytal.company/capytal/dislate/commands" - "github.com/bwmarrin/discordgo" -) - -type mockCommand struct{} - -func (c *mockCommand) Info() *discordgo.ApplicationCommand { - return &discordgo.ApplicationCommand{ - Name: "mock", - Type: discordgo.ChatApplicationCommand, - Description: "this is a mock command used for testing", - Options: []*discordgo.ApplicationCommandOption{{ - Name: "message", - Description: "The message to respond", - Type: discordgo.ApplicationCommandOptionString, - }}, - } -} - -func (c *mockCommand) Handle( - s *discordgo.Session, - ic *discordgo.InteractionCreate, - data discordgo.ApplicationCommandInteractionData, -) error { - if len(data.Options) == 0 { - return errors.New("Option \"message\" is not defined") - } - - text, ok := data.Options[0].Value.(string) - if !ok { - return errors.New("Failed to convert \"message\" into string") - } - - if _, err := s.ChannelMessageSendComplex(ic.ChannelID, &discordgo.MessageSend{ - Content: "test component", - Components: []discordgo.MessageComponent{ - discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - c.Components()[0].Info(), - }, - }, - }, - }); err != nil { - return err - } - - return s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("Hello user! Your text is %q", text), - Flags: discordgo.MessageFlagsEphemeral, - }, - }) -} - -func (c *mockCommand) Components() []commands.Component { - return []commands.Component{ - &mockButton{}, - } -} - -type mockButton struct{} - -func (c *mockButton) Info() discordgo.MessageComponent { - return &discordgo.Button{ - Label: "test button", - CustomID: "mock-command-test-button", - } -} - -func (c *mockButton) Handle( - s *discordgo.Session, - ic *discordgo.InteractionCreate, - data discordgo.MessageComponentInteractionData, -) error { - return s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("Button clicked!"), - Flags: discordgo.MessageFlagsEphemeral, - }, - }) -} diff --git a/commands/commands.go b/commands/commands.go deleted file mode 100644 index b9a9ebf..0000000 --- a/commands/commands.go +++ /dev/null @@ -1,228 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "log/slog" - - "github.com/bwmarrin/discordgo" -) - -type Command interface { - Info() *discordgo.ApplicationCommand - Handle( - s *discordgo.Session, - ic *discordgo.InteractionCreate, - data discordgo.ApplicationCommandInteractionData, - ) error -} - -type CommandWithComponents interface { - Command - Components() []Component -} - -type Component interface { - Info() discordgo.MessageComponent - Handle( - s *discordgo.Session, - ic *discordgo.InteractionCreate, - data discordgo.MessageComponentInteractionData, - ) error -} - -type ( - commandName = string - commandId = string - commandHandlerFunc = func(s *discordgo.Session, ic *discordgo.InteractionCreate, data discordgo.ApplicationCommandInteractionData) error - - componentCustomId = string - componentHandlerFunc = func(s *discordgo.Session, ic *discordgo.InteractionCreate, data discordgo.MessageComponentInteractionData) error -) - -type CommandsHandler struct { - logger *slog.Logger - session *discordgo.Session -} - -func NewCommandsHandler(logger *slog.Logger, session *discordgo.Session) *CommandsHandler { - return &CommandsHandler{logger, session} -} - -func (h *CommandsHandler) UpdateCommands( - commands []Command, - guildID ...string, -) error { - var GUILD_ID string - if len(guildID) > 0 { - GUILD_ID = guildID[0] - } - - commandsMap, err := h.mapCommands(commands) - if err != nil { - return err - } - - APP_ID := h.session.State.User.ID - if APP_ID == "" { - return errors.New("User ID is not set in session state") - } - - registeredCommands, err := h.mapRegisteredCommmands(GUILD_ID) - if err != nil { - return err - } - - if err := h.removeUnhandledCommands(commandsMap, registeredCommands, GUILD_ID); err != nil { - return err - } - - commandInteractionHandlers := make(map[commandName]commandHandlerFunc, len(commandsMap)) - componentInteractionHandlers := make(map[componentCustomId]componentHandlerFunc) - - for _, cmd := range commandsMap { - var err error - - appCmd, isRegistered := registeredCommands[cmd.Info().Name] - - if !isRegistered { - h.logger.Debug("Bot command is not registered in application, registering.", - slog.String("command_name", cmd.Info().Name), - slog.String("guild_id", GUILD_ID)) - - appCmd, err = h.session.ApplicationCommandCreate(APP_ID, GUILD_ID, cmd.Info()) - if err != nil { - return err - } - - } else if y, err := equalToRegistered(cmd.Info(), appCmd); !y { - h.logger.Debug("Bot command and registered command are different, deleting registered command for updating.", - slog.String("command_name", cmd.Info().Name), - slog.String("registered_command_id", appCmd.ID), - slog.String("registered_command_name", appCmd.Name), - slog.String("guild_id", GUILD_ID), - slog.String("difference", err.Error())) - - err = h.session.ApplicationCommandDelete(APP_ID, GUILD_ID, appCmd.ID) - if err != nil { - return err - } - - appCmd, err = h.session.ApplicationCommandCreate(APP_ID, GUILD_ID, cmd.Info()) - if err != nil { - return err - } - } - - if withCompsCmd, ok := cmd.(CommandWithComponents); ok { - for _, comp := range withCompsCmd.Components() { - var id string - - if comp.Info().Type() == discordgo.ActionsRowComponent { - // TODO - } else if comp.Info().Type() == discordgo.ButtonComponent { - button, ok := comp.Info().(*discordgo.Button) - if !ok { - return fmt.Errorf("Failed to convert ButtonComponent to Button struct on command %q", appCmd.Name) - } - - switch { - case button.CustomID == "" && button.URL == "": - return fmt.Errorf("Button component on command %q does not have a valid CustomID or URL", appCmd.Name) - case button.CustomID != "" && button.URL != "": - return fmt.Errorf("Button component on command %q has mutually exclusive CustomID and URL", appCmd.Name) - case button.CustomID != "": - id = button.CustomID - case button.URL != "": - id = button.URL - } - } else { - j, err := comp.Info().MarshalJSON() - if err != nil { - return err - } - - var v struct{ CustomID string } - if err := json.Unmarshal(j, &v); err != nil { - return err - } - - id = v.CustomID - } - - if _, ok := componentInteractionHandlers[id]; ok { - return fmt.Errorf( - "Component of ID %q used in command %q already exists!", - id, - appCmd.Name, - ) - } - - componentInteractionHandlers[id] = comp.Handle - } - } - - commandInteractionHandlers[appCmd.Name] = cmd.Handle - } - - h.session.AddHandler(func(s *discordgo.Session, ic *discordgo.InteractionCreate) { - h.handleInteraction(commandInteractionHandlers, componentInteractionHandlers, s, ic) - }) - - return nil -} - -func (h *CommandsHandler) mapCommands(commands []Command) (map[commandName]Command, error) { - m := make(map[commandName]Command, len(commands)) - - for _, c := range commands { - if n := c.Info().Name; n != "" { - m[c.Info().Name] = c - } else { - return m, fmt.Errorf("Command doesn't have a valid name!") - } - } - - return m, nil -} - -func (h *CommandsHandler) mapRegisteredCommmands( - guildID string, -) (map[commandName]*discordgo.ApplicationCommand, error) { - cmdMap := map[commandName]*discordgo.ApplicationCommand{} - - registeredCommands, err := h.session.ApplicationCommands(h.session.State.User.ID, guildID) - if err != nil { - return cmdMap, err - } - for _, rc := range registeredCommands { - cmdMap[rc.Name] = rc - } - - return cmdMap, nil -} - -func (h *CommandsHandler) removeUnhandledCommands( - handledCommands map[commandName]Command, - registeredCommands map[commandName]*discordgo.ApplicationCommand, - guildID string, -) error { - for _, cmd := range registeredCommands { - if _, isHandled := handledCommands[cmd.Name]; !isHandled { - h.logger.Debug("Registered command no longer is being handled, deleting.", - slog.String("registered_command_name", cmd.Name), - slog.String("registered_command_id", cmd.ID), - slog.String("guild_id", guildID)) - - err := h.session.ApplicationCommandDelete(h.session.State.User.ID, guildID, cmd.ID) - if err != nil { - return err - } - - delete(registeredCommands, cmd.Name) - } - } - - return nil -} diff --git a/commands/compare.go b/commands/compare.go deleted file mode 100644 index 5930866..0000000 --- a/commands/compare.go +++ /dev/null @@ -1,211 +0,0 @@ -package commands - -import ( - "errors" - "fmt" - "reflect" - - "github.com/bwmarrin/discordgo" -) - -// Helper function used inside equalToRegistered and equalToRegistered option, -// THIS ISN'T SUPPOSED TO BE USED outside of said functions. -func equal[T any](l, r T) bool { - lv := reflect.ValueOf(l) - - if lv.Kind() == reflect.Pointer { - // If the local value is a nil-pointer, we assume it as the - // zero-value of the underling value for comparison - var v any - if lv.IsNil() { - v = reflect.Zero(lv.Type().Elem()).Interface() - } else { - v = lv.Elem().Interface() - } - - rv := reflect.ValueOf(r) - - var rvv any - if rv.IsNil() { - rvv = reflect.Zero(rv.Type().Elem()).Interface() - } else { - rvv = rv.Elem().Interface() - } - - return equal(v, rvv) - } else { - return reflect.DeepEqual(l, r) - } -} - -// Helper function used inside equalToRegistered and equalToRegistered option, -// THIS ISN'T SUPPOSED TO BE USED outside of said functions. -func val[T any](v *T) T { - if v != nil { - return *v - } else { - return *new(T) - } -} - -func equalToRegistered(local, registered *discordgo.ApplicationCommand) (bool, error) { - switch { - case local.Type != registered.Type: - return false, fmt.Errorf( - "Type is not equal. Local: %#v Registered: %#v", - local.Type, - registered.Type, - ) - - case local.Name != registered.Name: - return false, fmt.Errorf( - "Name is not equal. Local: %#v Registered: %#v", - local.Name, - registered.Name, - ) - - case !equal(local.NameLocalizations, registered.NameLocalizations): - return false, fmt.Errorf( - "NameLocalizations is not equal. Local: *%#v Registered: *%#v", - val(local.NameLocalizations), - val(registered.NameLocalizations), - ) - - // DEPRECATED FIELDS https://discord.com/developers/docs/interactions/application-commands - // - // case !equal(local.DefaultMemberPermissions, registered.DefaultMemberPermissions): - // return false, fmt.Errorf( - // "DefaultMemberPermissions is not equal. Local: *%#v Registered: *%#v", - // val(local.DefaultMemberPermissions), - // val(registered.DefaultMemberPermissions), - // ) - // - // case - // !equal(local.DMPermission, registered.DMPermission): - // return false, fmt.Errorf( - // "DMPermission is not equal. Local: *%#v Registered: *%#v", - // val(local.DMPermission), - // val(registered.DMPermission), - // ) - - case !equal(local.NSFW, registered.NSFW): - return false, fmt.Errorf( - "VALUE is not equal. Local: *%#v Registered: *%#v", - val(local.NSFW), - val(registered.NSFW), - ) - - case local.Description != registered.Description: - return false, fmt.Errorf( - "Description is not equal. Local: %#v Registered: %#v", - local.Description, - registered.Description, - ) - - case !equal(local.DescriptionLocalizations, registered.DescriptionLocalizations): - return false, fmt.Errorf( - "DescriptionLocalizations is not equal. Local: *%#v Registered: *%#v", - val(local.DescriptionLocalizations), - val(registered.DescriptionLocalizations), - ) - - case len(local.Options) != len(registered.Options): - return false, fmt.Errorf( - "Options is not equal. Local: %#v Registered: %#v", - local.Options, - registered.Options, - ) - - case len(local.Options) > 0 && len(registered.Options) > 0: - for i, o := range local.Options { - if ok, err := equalToRegisteredOption(o, registered.Options[i]); !ok { - return ok, errors.Join(fmt.Errorf("Option element of index %v has difference", err)) - } - } - } - - return true, nil -} - -func equalToRegisteredOption(local, registered *discordgo.ApplicationCommandOption) (bool, error) { - switch { - case local.Type != registered.Type: - return false, fmt.Errorf( - "Type is not equal. Local: %#v Registered: %#v", - local.Type, - registered.Type, - ) - - case local.Name != registered.Name: - return false, fmt.Errorf( - "Name is not equal. Local: %#v Registered: %#v", - local.Name, - registered.Name, - ) - - case local.Description != registered.Description: - return false, fmt.Errorf( - "Description is not equal. Local: %#v Registered: %#v", - local.Description, - registered.Description, - ) - - case !equal(local.DescriptionLocalizations, registered.DescriptionLocalizations): - return false, fmt.Errorf( - "DescriptionLocalizations is not equal. Local: %#v Registered: %#v", - local.DescriptionLocalizations, - registered.DescriptionLocalizations, - ) - - case !equal(local.ChannelTypes, registered.ChannelTypes): - return false, fmt.Errorf( - "ChannelTypes is not equal. Local: %#v Registered: %#v", - local.ChannelTypes, - registered.ChannelTypes, - ) - - case local.Required != registered.Required: - return false, fmt.Errorf( - "Required is not equal. Local: %#v Registered: %#v", - local.Required, - registered.Required, - ) - - case !equal(local.Choices, registered.Choices): - return false, fmt.Errorf( - "Choices is not equal. Local: %#v Registered: %#v", - local.Choices, - registered.Choices, - ) - - case !equal(local.MinValue, registered.MinValue): - return false, fmt.Errorf( - "MinValue is not equal. Local: *%#v Registered: *%#v", - val(local.MinValue), - val(registered.MinValue), - ) - - case local.MaxValue != registered.MaxValue: - return false, fmt.Errorf( - "MaxValue is not equal. Local: %#v Registered: %#v", - local.MaxValue, - registered.MaxValue, - ) - - case !equal(local.MinLength, registered.MinLength): - return false, fmt.Errorf( - "MinLength is not equal. Local: *%#v Registered: *%#v", - val(local.MinLength), - val(registered.MinLength), - ) - - case local.MaxLength != registered.MaxLength: - return false, fmt.Errorf( - "MaxLength is not equal. Local: %#v Registered: %#v", - local.MaxLength, - registered.MaxLength, - ) - } - - return true, nil -} diff --git a/commands/interaction.go b/commands/interaction.go deleted file mode 100644 index 1a3edd0..0000000 --- a/commands/interaction.go +++ /dev/null @@ -1,139 +0,0 @@ -package commands - -import ( - "fmt" - "log/slog" - - "github.com/bwmarrin/discordgo" -) - -func (h *CommandsHandler) handleInteraction( - cmdsHandlers map[commandName]commandHandlerFunc, - compsHandlers map[componentCustomId]componentHandlerFunc, - s *discordgo.Session, - ic *discordgo.InteractionCreate, -) { - var userID string - if ic.User != nil { - userID = ic.User.ID - } else { - userID = ic.Member.User.ID - } - - switch ic.Type { - case discordgo.InteractionApplicationCommand: - h.handleCommandInteraction(cmdsHandlers, s, ic) - case discordgo.InteractionMessageComponent: - h.handleComponentInteraction(compsHandlers, s, ic) - default: - h.logger.Error("Application interaction is not a supported type!", - slog.String("interaction_id", ic.ID), - slog.String("interaction_type", ic.Type.String()), - slog.String("interaction_user_id", userID), - slog.String("interaction_guild_id", ic.GuildID), - ) - } -} - -func (h *CommandsHandler) handleCommandInteraction( - cmdsHandlers map[commandName]commandHandlerFunc, - s *discordgo.Session, - ic *discordgo.InteractionCreate, -) { - var userID string - if ic.User != nil { - userID = ic.User.ID - } else { - userID = ic.Member.User.ID - } - - data := ic.ApplicationCommandData() - - log := h.logger.With( - slog.String("command_data_id", data.ID), - slog.String("command_data_name", data.Name), - slog.String("interaction_user_id", userID), - slog.String("interaction_guild_id", ic.GuildID), - ) - - if hf, ok := cmdsHandlers[data.Name]; ok { - log.Debug("Handling application command.") - - if err := hf(s, ic, data); err != nil { - log.Error("Failed to run command, error returned.", slog.String("error", err.Error())) - - _, err = s.ChannelMessageSendComplex(ic.ChannelID, &discordgo.MessageSend{ - Content: fmt.Sprintf( - "<@%s>\nFailed to run command! Error message:\n```\n%s\n```", - userID, - err.Error(), - ), - }) - if err != nil { - log.Error( - "Failed to send error message explaining error... somehow.", - slog.String("error", err.Error()), - ) - } - - } else { - log.Debug("Command ran successfully.") - } - - } else { - log.Error("Application command interaction created without having a handler.") - } -} - -func (h *CommandsHandler) handleComponentInteraction( - compsHandlers map[componentCustomId]componentHandlerFunc, - s *discordgo.Session, - ic *discordgo.InteractionCreate, -) { - var userID string - if ic.User != nil { - userID = ic.User.ID - } else { - userID = ic.Member.User.ID - } - - data := ic.MessageComponentData() - - log := h.logger.With( - slog.String("component_data_custom_id", data.CustomID), - slog.String("component_data_type", data.Type().String()), - slog.String("interaction_user_id", userID), - slog.String("interaction_guild_id", ic.GuildID), - ) - - if hf, ok := compsHandlers[data.CustomID]; ok { - log.Debug("Handling application component.") - - if err := hf(s, ic, data); err != nil { - log.Error( - "Failed to handle component, error returned.", - slog.String("error", err.Error()), - ) - - _, err = s.ChannelMessageSendComplex(ic.ChannelID, &discordgo.MessageSend{ - Content: fmt.Sprintf( - "<@%s>\nFailed to handle component! Error message:\n```\n%s\n```", - userID, - err.Error(), - ), - }) - if err != nil { - log.Error( - "Failed to send error message explaining error... somehow.", - slog.String("error", err.Error()), - ) - } - - } else { - log.Debug("Component handled successfully.") - } - - } else { - log.Error("Application component interaction created without having a handler.") - } -}