diff options
| -rw-r--r-- | commands/config.go | 6 | ||||
| -rw-r--r-- | commands/stats.go | 2 | ||||
| -rw-r--r-- | commands/top.go | 12 | ||||
| -rw-r--r-- | config/channel.go | 4 | ||||
| -rw-r--r-- | config/guild.go | 136 | ||||
| -rw-r--r-- | config/xp_reduce.go | 4 | ||||
| -rw-r--r-- | config/xp_role.go | 368 | ||||
| -rw-r--r-- | config/xp_role_events.go | 358 | ||||
| -rw-r--r-- | events.go | 6 | ||||
| -rw-r--r-- | migrations/000-leave-gorm.sql | 2 | ||||
| -rw-r--r-- | rolereact/manager.go | 8 | ||||
| -rw-r--r-- | rolereact/views.go | 4 | ||||
| -rw-r--r-- | user/level.go | 4 | ||||
| -rw-r--r-- | user/member.go | 9 |
14 files changed, 513 insertions, 410 deletions
diff --git a/commands/config.go b/commands/config.go index f2dcf60..4535203 100644 --- a/commands/config.go +++ b/commands/config.go @@ -22,7 +22,7 @@ const ( ) func ConfigResponse(ctx context.Context, guildID uint64) *interaction.Response { - cfg := config.GetGuildConfig(ctx, guildID) + cfg := config.GetGuild(ctx, guildID) roles := "" l := len(cfg.XpRoles) - 1 slices.SortFunc(cfg.XpRoles, func(xp1, xp2 config.XpRole) int { @@ -30,9 +30,9 @@ func ConfigResponse(ctx context.Context, guildID uint64) *interaction.Response { }) for i, r := range cfg.XpRoles { if i == l { - roles += fmt.Sprintf("> Niveau %d - <@&%s>", exp.Level(r.XP), r.RoleID) + roles += fmt.Sprintf("> Niveau %d - <@&%d>", exp.Level(r.XP), r.RoleID) } else { - roles += fmt.Sprintf("> Niveau %d - <@&%s>\n", exp.Level(r.XP), r.RoleID) + roles += fmt.Sprintf("> Niveau %d - <@&%d>\n", exp.Level(r.XP), r.RoleID) } } if len(roles) == 0 { diff --git a/commands/stats.go b/commands/stats.go index c9562dc..9277ad7 100644 --- a/commands/stats.go +++ b/commands/stats.go @@ -51,7 +51,7 @@ var colors = []color.RGBA{ } func Stats(ctx context.Context, dg bot.Session, i *interaction.ApplicationCommand) { - cfg := config.GetGuildConfig(ctx, i.GuildID) + cfg := config.GetGuild(ctx, i.GuildID) days := 15 if common.IsDebug(ctx) { days = 90 diff --git a/commands/top.go b/commands/top.go index db44dbd..d816c55 100644 --- a/commands/top.go +++ b/commands/top.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strings" "sync" "git.anhgelus.world/anhgelus/les-copaings-bot/config" @@ -26,7 +27,7 @@ func Top(ctx context.Context, dg bot.Session, i *interaction.ApplicationCommand) } } - cfg := config.GetGuildConfig(ctx, i.GuildID) + cfg := config.GetGuild(ctx, i.GuildID) if cfg.DaysXPRemains > 30 { wg.Go(func() { fn(fmt.Sprintf("Top %d jours", cfg.DaysXPRemains), 10, -1, 0) @@ -57,12 +58,13 @@ func Top(ctx context.Context, dg bot.Session, i *interaction.ApplicationCommand) } func genTopsMessage(tops []user.CopaingCached) string { - msg := "" + var sb strings.Builder for i, c := range tops { - msg += fmt.Sprintf("%d. **<@%d>** - niveau %d", i+1, c.ID, exp.Level(c.XP)) + ft := fmt.Sprintf("%d. **<@%d>** - niveau %d", i+1, c.ID, exp.Level(c.XP)) + sb.WriteString(ft) if i != len(tops)-1 { - msg += "\n" + sb.WriteString("\n") } } - return msg + return sb.String() } diff --git a/config/channel.go b/config/channel.go index 5cc88aa..a015aef 100644 --- a/config/channel.go +++ b/config/channel.go @@ -21,7 +21,7 @@ const ( ) func HandleModifyFallbackChannel(ctx context.Context, dg bot.Session, i *interaction.MessageComponent) bool { - cfg := GetGuildConfig(ctx, i.GuildID) + cfg := GetGuild(ctx, i.GuildID) var channelID uint64 if len(i.Data.Values) > 0 { var err error @@ -40,7 +40,7 @@ func HandleModifyFallbackChannel(ctx context.Context, dg bot.Session, i *interac } func HandleModifyDisChannel(ctx context.Context, dg bot.Session, i *interaction.MessageComponent) bool { - cfg := GetGuildConfig(ctx, i.GuildID) + cfg := GetGuild(ctx, i.GuildID) cfg.DisabledChannels = strings.Join(i.Data.Values, ";") err := cfg.Save(ctx) if err != nil { diff --git a/config/guild.go b/config/guild.go index dc8251f..aec78ed 100644 --- a/config/guild.go +++ b/config/guild.go @@ -2,31 +2,33 @@ package config import ( "context" + "database/sql" + "errors" "fmt" + "slices" "strings" + "git.anhgelus.world/anhgelus/les-copaings-bot/common" "github.com/nyttikord/gokord/bot" "github.com/nyttikord/gokord/channel" ) type Guild struct { - ID uint `gorm:"primarykey"` - GuildID uint64 `gorm:"not null;unique"` - XpRoles []XpRole `gorm:"foreignKey:GuildConfigID"` + ID uint64 + XpRoles []XpRole DisabledChannels string FallbackChannel uint64 - DaysXPRemains uint `gorm:"default:90"` // 30 * 3 = 90 (three months) + DaysXPRemains uint RrMessages []RoleReactMessage } type RoleReactMessage struct { - ID uint `gorm:"primarykey"` - MessageID uint64 `gorm:"not null;unique"` - ChannelID uint64 - GuildID uint64 - Note string - Roles []*RoleReact - GuildConfigID uint + ID uint `gorm:"primarykey"` + MessageID uint64 `gorm:"not null;unique"` + ChannelID uint64 + GuildID uint64 + Note string + Roles []*RoleReact } type RoleReact struct { @@ -37,45 +39,102 @@ type RoleReact struct { CounterID uint `gorm:"-"` } -func GetGuildConfig(ctx context.Context, guildID uint64) *Guild { - cfg := Guild{GuildID: guildID} +func GetGuild(ctx context.Context, guildID uint64) *Guild { + cfg := Guild{ID: guildID} if err := cfg.Load(ctx); err != nil { panic(err) } return &cfg } -func (cfg *Guild) Load(ctx context.Context) error { - return nil //common.GetDB(ctx).Where("guild_id = ?", cfg.GuildID).Preload("XpRoles").FirstOrCreate(cfg).Error +func (g *Guild) Load(ctx context.Context) error { + db := common.GetDB(ctx) + row := db.QueryRowContext( + ctx, + `SELECT disabled_channels, fallback_channel, days_xp_remains FROM guilds WHERE id = ?`, + g.ID, + ) + g.RrMessages = nil + g.XpRoles = nil + err := row.Err() + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = db.ExecContext( + ctx, + `INSERT INTO guilds (id) VALUES (?)`, + g.ID, + ) + return err + } + err = row.Scan(&g.DisabledChannels, &g.FallbackChannel, &g.DaysXPRemains) + if err != nil { + return err + } + g.XpRoles, err = getXpRoles(ctx, g.ID) + return err } -func (cfg *Guild) Save(ctx context.Context) error { - return nil //common.GetDB(ctx).Save(cfg).Error +func (g *Guild) Save(ctx context.Context) error { + db := common.GetDB(ctx) + _, err := db.ExecContext( + ctx, + `UPDATE guilds SET disabled_channels = ?, fallback_channel = ?, days_xp_remains = ? WHERE id = ?`, + g.DisabledChannels, g.FallbackChannel, g.DaysXPRemains, g.ID, + ) + if err != nil { + return err + } + savedRoles, err := getXpRoles(ctx, g.ID) + if err != nil { + return err + } + roleEqual := func(base XpRole) func(XpRole) bool { + return func(v XpRole) bool { + return v.GuildID == base.GuildID && v.RoleID == base.RoleID && v.XP == base.XP + } + } + // super slow code, but I don't want to implement a data structure to optimize this + for _, role := range g.XpRoles { + if !slices.ContainsFunc(savedRoles, roleEqual(role)) { + err = role.Save(ctx) + if err != nil { + return err + } + } + } + for _, role := range savedRoles { + if !slices.ContainsFunc(g.XpRoles, roleEqual(role)) { + err = role.Delete(ctx) + if err != nil { + return err + } + } + } + return nil } -func (cfg *Guild) IsDisabled(ctx context.Context, dg bot.Session, channelID uint64) bool { +func (g *Guild) IsDisabled(ctx context.Context, dg bot.Session, channelID uint64) bool { ok := true for channelID != 0 && ok { - ok = !strings.Contains(cfg.DisabledChannels, fmt.Sprintf("%d", channelID)) + ok = !strings.Contains(g.DisabledChannels, fmt.Sprintf("%d", channelID)) c, err := dg.ChannelState().GetChannel(channelID) if err != nil { - bot.Logger(ctx).Error("unable to find channel %s in state", "error", err, "channel", c) + bot.Logger(ctx).Warn("unable to find channel %s in state", "error", err, "channel", c) c, err = channel.Get(channelID).Do(ctx) if err != nil { bot.Logger(ctx).Error("unable to fetch channel", "error", err, "channel", c) return false } } - if err != nil { - return false - } channelID = c.ParentID } return !ok } -func (cfg *Guild) FindXpRole(roleID uint64) (int, *XpRole) { - for i, r := range cfg.XpRoles { +func (g *Guild) FindXpRole(roleID uint64) (int, *XpRole) { + for i, r := range g.XpRoles { if r.RoleID == roleID { return i, &r } @@ -83,11 +142,28 @@ func (cfg *Guild) FindXpRole(roleID uint64) (int, *XpRole) { return 0, nil } -func (cfg *Guild) FindXpRoleID(ID uint) (int, *XpRole) { - for i, r := range cfg.XpRoles { - if r.ID == ID { - return i, &r +func getXpRoles(ctx context.Context, gID uint64) ([]XpRole, error) { + roles := make([]XpRole, 0) + rows, err := common.GetDB(ctx).QueryContext( + ctx, + `SELECT xp, role FROM xp_roles WHERE guild_id = ?`, + gID, + ) + if err == nil { + defer rows.Close() + for rows.Next() { + var role XpRole + err = rows.Scan(&role.XP, &role.RoleID) + if err != nil { + return roles, err + } + role.GuildID = gID + roles = append(roles, role) + } + } else { + if !errors.Is(err, sql.ErrNoRows) { + return roles, err } } - return -1, nil + return roles, nil } diff --git a/config/xp_reduce.go b/config/xp_reduce.go index 36e64ed..baa4586 100644 --- a/config/xp_reduce.go +++ b/config/xp_reduce.go @@ -16,7 +16,7 @@ const ( ) func HandleModifyPeriodicReduceCommand(ctx context.Context, dg bot.Session, i *interaction.MessageComponent) { - cfg := GetGuildConfig(ctx, i.GuildID) + cfg := GetGuild(ctx, i.GuildID) resp := interaction.NewModalResponse(). CustomID(TimeReduceSet). Title("Modifier la durée de l'expérience"). @@ -66,7 +66,7 @@ func HandleTimeReduceSet(ctx context.Context, dg bot.Session, i *interaction.Mod } return false } - cfg := GetGuildConfig(ctx, i.GuildID) + cfg := GetGuild(ctx, i.GuildID) cfg.DaysXPRemains = uint(days) err = cfg.Save(ctx) if err != nil { diff --git a/config/xp_role.go b/config/xp_role.go index 81082e8..f0d57c8 100644 --- a/config/xp_role.go +++ b/config/xp_role.go @@ -2,364 +2,30 @@ package config import ( "context" - "fmt" - "slices" - "strconv" - "git.anhgelus.world/anhgelus/les-copaings-bot/dynamicid" - "git.anhgelus.world/anhgelus/les-copaings-bot/exp" - "github.com/nyttikord/gokord/bot" - "github.com/nyttikord/gokord/channel" - "github.com/nyttikord/gokord/component" - "github.com/nyttikord/gokord/discord/types" - "github.com/nyttikord/gokord/interaction" + "git.anhgelus.world/anhgelus/les-copaings-bot/common" ) type XpRole struct { - ID uint `gorm:"primarykey"` - XP uint - RoleID uint64 - GuildConfigID uint + XP uint + RoleID uint64 + GuildID uint64 } -type XpRoleId struct { - ID uint -} - -const ( - ModifyXpRole = "xp_role" - XpRoleNew = "xp_role_add" - XpRoleAdd = "xp_role_add_level" - XpRoleEdit = `xp_role_edit` - XpRoleEditLevel = `xp_role_edit_level` - XpRoleEditLevelStart = `xp_role_edit_level_start` - XpRoleEditRole = `xp_role_edit_role` - XpRoleDel = `xp_role_del` -) - -func HandleXpRole(ctx context.Context, dg bot.Session, i *interaction.Interaction) { - cfg := GetGuildConfig(ctx, i.GuildID) - container := component.Container{ - Components: []component.Message{ - &component.TextDisplay{Content: "## Configuration / Rôles de niveaux"}, - &component.TextDisplay{Content: "Ces rôles seront donnés et retirés en fonction du niveau de chacun"}, - &component.Separator{}, - }, - } - slices.SortFunc(cfg.XpRoles, func(xp1, xp2 XpRole) int { - return int(xp2.XP) - int(xp1.XP) - }) - for _, r := range cfg.XpRoles { - container.Components = append(container.Components, &component.Section{ - Components: []component.Message{ - &component.TextDisplay{ - Content: fmt.Sprintf("<@&%s> - Niveau %d", r.RoleID, exp.Level(r.XP)), - }, - }, - Accessory: &component.Button{ - CustomID: dynamicid.FormatCustomID(XpRoleEdit, XpRoleId{ID: r.ID}), - Style: component.ButtonStyleSecondary, - Label: "Modifier", - }, - }) - } - container.Components = append(container.Components, - &component.ActionsRow{ - Components: []component.Message{ - &component.Button{ - CustomID: XpRoleNew, - Style: component.ButtonStylePrimary, - Label: "Nouveau rôle", - }, - }, - }, - &component.Separator{}, - &component.ActionsRow{ - Components: []component.Message{ - &component.Button{CustomID: "config", Style: component.ButtonStyleSecondary, Label: "Retour"}, - }, - }, +func (xp XpRole) Save(ctx context.Context) error { + _, err := common.GetDB(ctx).ExecContext( + ctx, + `INSERT INTO xp_roles (xp, role, guild_id) VALUES (?, ?, ?)`, + xp.XP, xp.RoleID, xp.GuildID, ) - - response := &interaction.Response{ - Type: types.InteractionResponseUpdateMessage, - Data: &interaction.ResponseData{ - Components: []component.Component{&container}, - Flags: channel.MessageFlagsIsComponentsV2, - }, - } - err := interaction.Respond(i, response).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending config", "error", err) - } -} - -func HandleXpRoleNew(ctx context.Context, dg bot.Session, i *interaction.MessageComponent) { - one := 1 - resp := interaction.NewModalResponse(). - Title("Nouveau rôle de niveau"). - CustomID(XpRoleAdd). - AddComponent(&component.Label{ - Label: "Niveau", - Component: &component.TextInput{ - CustomID: "level", - Style: component.TextInputShort, - Placeholder: "5", - MinLength: 1, - MaxLength: 5, - Required: true, - }, - }). - AddComponent(&component.Label{ - Label: "Rôle", - Component: &component.SelectMenu{ - MenuType: types.SelectMenuRole, - CustomID: "role", - MinValues: &one, - MaxValues: one, - }, - }). - Response() - err := interaction.Respond(i.Interaction, resp).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending modal to add", "error", err) - } -} - -func HandleXpRoleEdit(ctx context.Context, dg bot.Session, i *interaction.Interaction, params *XpRoleId) { - config := GetGuildConfig(ctx, i.GuildID) - id := params.ID - _, role := config.FindXpRoleID(id) - if role == nil { - HandleXpRole(ctx, dg, i) - return - } - - roleSelect := &component.SelectMenu{ - MenuType: types.SelectMenuRole, - CustomID: dynamicid.FormatCustomID(XpRoleEditRole, XpRoleId{ID: id}), - DefaultValues: []component.SelectMenuDefaultValue{ - {ID: role.RoleID, Type: types.SelectMenuDefaultValueRole}, - }, - } - - container := &component.Container{ - Components: []component.Message{ - &component.TextDisplay{Content: "## Configuration / Rôles de niveaux"}, - &component.Separator{}, - &component.Section{ - Components: []component.Message{ - &component.TextDisplay{Content: fmt.Sprintf("Niveau **%d**", exp.Level(role.XP))}, - }, - Accessory: &component.Button{ - CustomID: dynamicid.FormatCustomID(XpRoleEditLevelStart, XpRoleId{ID: id}), - Style: component.ButtonStyleSecondary, - Label: "Modifier", - }, - }, - &component.ActionsRow{Components: []component.Message{roleSelect}}, - &component.ActionsRow{Components: []component.Message{ - &component.Button{ - CustomID: dynamicid.FormatCustomID(XpRoleDel, XpRoleId{ID: id}), - Style: component.ButtonStyleDanger, - Label: "Supprimer", - }, - }}, - &component.Separator{}, - &component.ActionsRow{Components: []component.Message{ - &component.Button{Label: "Retour", CustomID: ModifyXpRole, Style: component.ButtonStyleSecondary}, - }}, - }, - } - - response := &interaction.Response{ - Type: types.InteractionResponseUpdateMessage, - Data: &interaction.ResponseData{ - Components: []component.Component{container}, - Flags: channel.MessageFlagsIsComponentsV2, - }, - } - - err := interaction.Respond(i, response).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending xp_role config", "error", err) - } -} - -func HandleXpRoleEditRole(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, params *XpRoleId) { - id := params.ID - role, err := strconv.ParseUint(i.Data.Values[0], 10, 64) - if err != nil { - // panic because must ensure that the role is valid before - panic(err) - } - cfg := GetGuildConfig(ctx, i.GuildID) - _, xpRole := cfg.FindXpRoleID(id) - if xpRole == nil { - err := interaction.Respond(i.Interaction, &interaction.Response{ - Type: types.InteractionResponseChannelMessageWithSource, - Data: &interaction.ResponseData{ - Flags: channel.MessageFlagsEphemeral, - Content: "Impossible de modifier le rôle. Peut-être a-t-il été supprimé ?", - }, - }).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending unable to get role message", "error", err) - } - return - } - xpRole.RoleID = role - err = nil // common.GetDB(ctx).Save(xpRole).Error - if err != nil { - bot.Logger(ctx).Error("saving config", "error", err, "guild", i.GuildID, "id", id, "type", "add") - } - HandleXpRoleEdit(ctx, dg, i.Interaction, params) + return err } -func HandleXpRoleEditLevelStart(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, params *XpRoleId) { - id := params.ID - cfg := GetGuildConfig(ctx, i.GuildID) - _, xpRole := cfg.FindXpRoleID(id) - if xpRole == nil { - err := interaction.Respond(i.Interaction, &interaction.Response{ - Type: types.InteractionResponseChannelMessageWithSource, - Data: &interaction.ResponseData{ - Flags: channel.MessageFlagsEphemeral, - Content: "Impossible de trouver le rôle. Peut-être a-t-il été supprimé ?", - }, - }).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending unable to get role message", "error", err) - } - return - } - response := &interaction.Response{ - Type: types.InteractionResponseModal, - Data: &interaction.ResponseData{ - Title: "Modification du niveau lié au rôle", - CustomID: dynamicid.FormatCustomID(XpRoleEditLevel, XpRoleId{ID: id}), - Components: []component.Component{ - &component.Label{ - Label: "Nouveau niveau", - Component: &component.TextInput{ - Style: component.TextInputShort, - Required: true, - CustomID: "level", - MinLength: 1, - MaxLength: 5, - Placeholder: "5", - Value: strconv.FormatUint(uint64(exp.Level(xpRole.XP)), 10), - }, - }, - }, - }, - } - err := interaction.Respond(i.Interaction, response).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending edit level modal", "error", err) - } -} - -func HandleXpRoleEditLevel(ctx context.Context, dg bot.Session, i *interaction.ModalSubmit, params *XpRoleId) { - id := params.ID - - levelInput := i.Data.Components[0].(*component.Label).Component.(*component.TextInput) - level, err := strconv.Atoi(levelInput.Value) - if err != nil || level < 0 { - resp := interaction.NewMessageResponse(). - IsEphemeral(). - Message(fmt.Sprintf("Le niveau doit être un nombre entier positif.\n-# Trouvé : %s", levelInput.Value)). - Response() - err = interaction.Respond(i.Interaction, resp).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending bad number warning message", "error", err) - } - return - } - xp := exp.LevelXP(uint(level)) - - cfg := GetGuildConfig(ctx, i.GuildID) - _, xpRole := cfg.FindXpRoleID(id) - if xpRole == nil { - err = interaction.Respond(i.Interaction, &interaction.Response{ - Type: types.InteractionResponseChannelMessageWithSource, - Data: &interaction.ResponseData{ - Flags: channel.MessageFlagsEphemeral, - Content: "Impossible de modifier le rôle. Peut-être a-t-il été supprimé ?", - }, - }).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending unable to modify role message", "error", err) - } - return - } - xpRole.XP = xp - err = nil //common.GetDB(ctx).Save(xpRole).Error - if err != nil { - bot.Logger(ctx).Error("saving config", "guild", i.GuildID, "id", id, "type", "edit") - } - HandleXpRoleEdit(ctx, dg, i.Interaction, params) -} - -func HandleXpRoleDel(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, parameters *XpRoleId) { - id := parameters.ID - cfg := GetGuildConfig(ctx, i.GuildID) - _, role := cfg.FindXpRoleID(id) - if role == nil { - err := interaction.Respond(i.Interaction, &interaction.Response{ - Type: types.InteractionResponseChannelMessageWithSource, - Data: &interaction.ResponseData{ - Content: "Rôle introuvable. Peut-être a-t-il déjà été supprimé ?", - Flags: channel.MessageFlagsEphemeral, - }, - }).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending role not found message", "error", err) - } - return - } - var err error = nil //common.GetDB(ctx).Delete(role).Error - if err != nil { - bot.Logger(ctx).Error("deleting entry", "error", err, "guild", i.GuildID, "id", id, "type", "del") - } - - HandleXpRole(ctx, dg, i.Interaction) -} - -func HandleXpRoleAdd(ctx context.Context, dg bot.Session, i *interaction.ModalSubmit) { - levelInput := i.Data.Components[0].(*component.Label).Component.(*component.TextInput) - - in, err := strconv.Atoi(levelInput.Value) - if err != nil || in < 0 { - resp := interaction.NewMessageResponse(). - IsEphemeral(). - Message(fmt.Sprintf("Le niveau doit être un nombre entier positif.\n-# Trouvé : %s", levelInput.Value)). - Response() - err = interaction.Respond(i.Interaction, resp).Do(ctx) - if err != nil { - bot.Logger(ctx).Error("sending bad number warning message", "error", err) - } - return - } - xp := exp.LevelXP(uint(in)) - - rawRoleId := i.Data.Components[1].(*component.Label).Component.(*component.SelectMenu).Values[0] - roleId, err := strconv.ParseUint(rawRoleId, 10, 64) - if err != nil { - // panic because select menu must ensure that the value is valid - panic(err) - } - - cfg := GetGuildConfig(ctx, i.GuildID) - cfg.XpRoles = append(cfg.XpRoles, XpRole{ - XP: xp, - RoleID: roleId, - }) - err = cfg.Save(ctx) - if err != nil { - bot.Logger(ctx).Error("saving config", "error", err, "role", roleId, "guild", i.GuildID) - return - } - - HandleXpRole(ctx, dg, i.Interaction) +func (xp XpRole) Delete(ctx context.Context) error { + _, err := common.GetDB(ctx).ExecContext( + ctx, + `DELETE FROM xp_roles WHERE xp = ? AND role = ? AND guild_id = ?`, + xp.XP, xp.RoleID, xp.GuildID, + ) + return err } diff --git a/config/xp_role_events.go b/config/xp_role_events.go new file mode 100644 index 0000000..cda90d5 --- /dev/null +++ b/config/xp_role_events.go @@ -0,0 +1,358 @@ +package config + +import ( + "context" + "fmt" + "slices" + "strconv" + + "git.anhgelus.world/anhgelus/les-copaings-bot/dynamicid" + "git.anhgelus.world/anhgelus/les-copaings-bot/exp" + "github.com/nyttikord/gokord/bot" + "github.com/nyttikord/gokord/channel" + "github.com/nyttikord/gokord/component" + "github.com/nyttikord/gokord/discord/types" + "github.com/nyttikord/gokord/interaction" +) + +type XpRoleId struct { + ID uint64 +} + +const ( + ModifyXpRole = "xp_role" + XpRoleNew = "xp_role_add" + XpRoleAdd = "xp_role_add_level" + XpRoleEdit = `xp_role_edit` + XpRoleEditLevel = `xp_role_edit_level` + XpRoleEditLevelStart = `xp_role_edit_level_start` + XpRoleEditRole = `xp_role_edit_role` + XpRoleDel = `xp_role_del` +) + +func HandleXpRole(ctx context.Context, dg bot.Session, i *interaction.Interaction) { + cfg := GetGuild(ctx, i.GuildID) + container := component.Container{ + Components: []component.Message{ + &component.TextDisplay{Content: "## Configuration / Rôles de niveaux"}, + &component.TextDisplay{Content: "Ces rôles seront donnés et retirés en fonction du niveau de chacun"}, + &component.Separator{}, + }, + } + slices.SortFunc(cfg.XpRoles, func(xp1, xp2 XpRole) int { + return int(xp2.XP) - int(xp1.XP) + }) + for _, r := range cfg.XpRoles { + container.Components = append(container.Components, &component.Section{ + Components: []component.Message{ + &component.TextDisplay{ + Content: fmt.Sprintf("<@&%d> - Niveau %d", r.RoleID, exp.Level(r.XP)), + }, + }, + Accessory: &component.Button{ + CustomID: dynamicid.FormatCustomID(XpRoleEdit, XpRoleId{ID: r.RoleID}), + Style: component.ButtonStyleSecondary, + Label: "Modifier", + }, + }) + } + container.Components = append(container.Components, + &component.ActionsRow{ + Components: []component.Message{ + &component.Button{ + CustomID: XpRoleNew, + Style: component.ButtonStylePrimary, + Label: "Nouveau rôle", + }, + }, + }, + &component.Separator{}, + &component.ActionsRow{ + Components: []component.Message{ + &component.Button{CustomID: "config", Style: component.ButtonStyleSecondary, Label: "Retour"}, + }, + }, + ) + + response := &interaction.Response{ + Type: types.InteractionResponseUpdateMessage, + Data: &interaction.ResponseData{ + Components: []component.Component{&container}, + Flags: channel.MessageFlagsIsComponentsV2, + }, + } + err := interaction.Respond(i, response).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending config", "error", err) + } +} + +func HandleXpRoleNew(ctx context.Context, dg bot.Session, i *interaction.MessageComponent) { + one := 1 + resp := interaction.NewModalResponse(). + Title("Nouveau rôle de niveau"). + CustomID(XpRoleAdd). + AddComponent(&component.Label{ + Label: "Niveau", + Component: &component.TextInput{ + CustomID: "level", + Style: component.TextInputShort, + Placeholder: "5", + MinLength: 1, + MaxLength: 5, + Required: true, + }, + }). + AddComponent(&component.Label{ + Label: "Rôle", + Component: &component.SelectMenu{ + MenuType: types.SelectMenuRole, + CustomID: "role", + MinValues: &one, + MaxValues: one, + }, + }). + Response() + err := interaction.Respond(i.Interaction, resp).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending modal to add", "error", err) + } +} + +func HandleXpRoleEdit(ctx context.Context, dg bot.Session, i *interaction.Interaction, params *XpRoleId) { + config := GetGuild(ctx, i.GuildID) + id := params.ID + _, role := config.FindXpRole(id) + if role == nil { + HandleXpRole(ctx, dg, i) + return + } + + roleSelect := &component.SelectMenu{ + MenuType: types.SelectMenuRole, + CustomID: dynamicid.FormatCustomID(XpRoleEditRole, XpRoleId{ID: id}), + DefaultValues: []component.SelectMenuDefaultValue{ + {ID: role.RoleID, Type: types.SelectMenuDefaultValueRole}, + }, + } + + container := &component.Container{ + Components: []component.Message{ + &component.TextDisplay{Content: "## Configuration / Rôles de niveaux"}, + &component.Separator{}, + &component.Section{ + Components: []component.Message{ + &component.TextDisplay{Content: fmt.Sprintf("Niveau **%d**", exp.Level(role.XP))}, + }, + Accessory: &component.Button{ + CustomID: dynamicid.FormatCustomID(XpRoleEditLevelStart, XpRoleId{ID: id}), + Style: component.ButtonStyleSecondary, + Label: "Modifier", + }, + }, + &component.ActionsRow{Components: []component.Message{roleSelect}}, + &component.ActionsRow{Components: []component.Message{ + &component.Button{ + CustomID: dynamicid.FormatCustomID(XpRoleDel, XpRoleId{ID: id}), + Style: component.ButtonStyleDanger, + Label: "Supprimer", + }, + }}, + &component.Separator{}, + &component.ActionsRow{Components: []component.Message{ + &component.Button{Label: "Retour", CustomID: ModifyXpRole, Style: component.ButtonStyleSecondary}, + }}, + }, + } + + response := &interaction.Response{ + Type: types.InteractionResponseUpdateMessage, + Data: &interaction.ResponseData{ + Components: []component.Component{container}, + Flags: channel.MessageFlagsIsComponentsV2, + }, + } + + err := interaction.Respond(i, response).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending xp_role config", "error", err) + } +} + +func HandleXpRoleEditRole(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, params *XpRoleId) { + id := params.ID + role, err := strconv.ParseUint(i.Data.Values[0], 10, 64) + if err != nil { + // panic because must ensure that the role is valid before + panic(err) + } + cfg := GetGuild(ctx, i.GuildID) + _, xpRole := cfg.FindXpRole(id) + if xpRole == nil { + err := interaction.Respond(i.Interaction, &interaction.Response{ + Type: types.InteractionResponseChannelMessageWithSource, + Data: &interaction.ResponseData{ + Flags: channel.MessageFlagsEphemeral, + Content: "Impossible de modifier le rôle. Peut-être a-t-il été supprimé ?", + }, + }).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending unable to get role message", "error", err) + } + return + } + xpRole.RoleID = role + err = xpRole.Save(ctx) + if err != nil { + bot.Logger(ctx).Error("saving config", "error", err, "guild", i.GuildID, "id", id, "type", "add") + } + HandleXpRoleEdit(ctx, dg, i.Interaction, params) +} + +func HandleXpRoleEditLevelStart(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, params *XpRoleId) { + id := params.ID + cfg := GetGuild(ctx, i.GuildID) + _, xpRole := cfg.FindXpRole(id) + if xpRole == nil { + err := interaction.Respond(i.Interaction, &interaction.Response{ + Type: types.InteractionResponseChannelMessageWithSource, + Data: &interaction.ResponseData{ + Flags: channel.MessageFlagsEphemeral, + Content: "Impossible de trouver le rôle. Peut-être a-t-il été supprimé ?", + }, + }).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending unable to get role message", "error", err) + } + return + } + response := &interaction.Response{ + Type: types.InteractionResponseModal, + Data: &interaction.ResponseData{ + Title: "Modification du niveau lié au rôle", + CustomID: dynamicid.FormatCustomID(XpRoleEditLevel, XpRoleId{ID: id}), + Components: []component.Component{ + &component.Label{ + Label: "Nouveau niveau", + Component: &component.TextInput{ + Style: component.TextInputShort, + Required: true, + CustomID: "level", + MinLength: 1, + MaxLength: 5, + Placeholder: "5", + Value: strconv.FormatUint(uint64(exp.Level(xpRole.XP)), 10), + }, + }, + }, + }, + } + err := interaction.Respond(i.Interaction, response).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending edit level modal", "error", err) + } +} + +func HandleXpRoleEditLevel(ctx context.Context, dg bot.Session, i *interaction.ModalSubmit, params *XpRoleId) { + id := params.ID + + levelInput := i.Data.Components[0].(*component.Label).Component.(*component.TextInput) + level, err := strconv.Atoi(levelInput.Value) + if err != nil || level < 0 { + resp := interaction.NewMessageResponse(). + IsEphemeral(). + Message(fmt.Sprintf("Le niveau doit être un nombre entier positif.\n-# Trouvé : %s", levelInput.Value)). + Response() + err = interaction.Respond(i.Interaction, resp).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending bad number warning message", "error", err) + } + return + } + xp := exp.LevelXP(uint(level)) + + cfg := GetGuild(ctx, i.GuildID) + _, xpRole := cfg.FindXpRole(id) + if xpRole == nil { + err = interaction.Respond(i.Interaction, &interaction.Response{ + Type: types.InteractionResponseChannelMessageWithSource, + Data: &interaction.ResponseData{ + Flags: channel.MessageFlagsEphemeral, + Content: "Impossible de modifier le rôle. Peut-être a-t-il été supprimé ?", + }, + }).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending unable to modify role message", "error", err) + } + return + } + xpRole.XP = xp + err = xpRole.Save(ctx) + if err != nil { + bot.Logger(ctx).Error("saving config", "guild", i.GuildID, "id", id, "type", "edit") + } + HandleXpRoleEdit(ctx, dg, i.Interaction, params) +} + +func HandleXpRoleDel(ctx context.Context, dg bot.Session, i *interaction.MessageComponent, parameters *XpRoleId) { + id := parameters.ID + cfg := GetGuild(ctx, i.GuildID) + _, role := cfg.FindXpRole(id) + if role == nil { + err := interaction.Respond(i.Interaction, &interaction.Response{ + Type: types.InteractionResponseChannelMessageWithSource, + Data: &interaction.ResponseData{ + Content: "Rôle introuvable. Peut-être a-t-il déjà été supprimé ?", + Flags: channel.MessageFlagsEphemeral, + }, + }).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending role not found message", "error", err) + } + return + } + err := role.Delete(ctx) + if err != nil { + bot.Logger(ctx).Error("deleting entry", "error", err, "guild", i.GuildID, "id", id, "type", "del") + } + + HandleXpRole(ctx, dg, i.Interaction) +} + +func HandleXpRoleAdd(ctx context.Context, dg bot.Session, i *interaction.ModalSubmit) { + levelInput := i.Data.Components[0].(*component.Label).Component.(*component.TextInput) + + in, err := strconv.Atoi(levelInput.Value) + if err != nil || in < 0 { + resp := interaction.NewMessageResponse(). + IsEphemeral(). + Message(fmt.Sprintf("Le niveau doit être un nombre entier positif.\n-# Trouvé : %s", levelInput.Value)). + Response() + err = interaction.Respond(i.Interaction, resp).Do(ctx) + if err != nil { + bot.Logger(ctx).Error("sending bad number warning message", "error", err) + } + return + } + xp := exp.LevelXP(uint(in)) + + rawRoleId := i.Data.Components[1].(*component.Label).Component.(*component.SelectMenu).Values[0] + roleId, err := strconv.ParseUint(rawRoleId, 10, 64) + if err != nil { + // panic because select menu must ensure that the value is valid + panic(err) + } + + cfg := GetGuild(ctx, i.GuildID) + cfg.XpRoles = append(cfg.XpRoles, XpRole{ + XP: xp, + RoleID: roleId, + }) + err = cfg.Save(ctx) + if err != nil { + bot.Logger(ctx).Error("saving config", "error", err, "role", roleId, "guild", i.GuildID) + return + } + + HandleXpRole(ctx, dg, i.Interaction) +} @@ -28,7 +28,7 @@ func OnMessage(ctx context.Context, dg bot.Session, m *event.MessageCreate) { if m.Author.Bot { return } - cfg := config.GetGuildConfig(ctx, m.GuildID) + cfg := config.GetGuild(ctx, m.GuildID) if cfg.IsDisabled(ctx, dg, m.ChannelID) { return } @@ -52,7 +52,7 @@ func OnVoiceUpdate(ctx context.Context, dg bot.Session, e *event.VoiceStateUpdat if e.Member.User.Bot { return } - cfg := config.GetGuildConfig(ctx, e.GuildID) + cfg := config.GetGuild(ctx, e.GuildID) dis := cfg.IsDisabled(ctx, dg, e.BeforeUpdate.ChannelID) if (e.BeforeUpdate == nil || dis) && e.ChannelID != 0 { if dis { @@ -99,7 +99,7 @@ func onDisconnect(ctx context.Context, dg bot.Session, e *event.VoiceStateUpdate timeInVocal = min(timeInVocal, MaxTimeInVocal) e.Member.GuildID = e.GuildID cc.AddXP(ctx, dg, e.Member, exp.VocalXP(uint(timeInVocal)), func(_ uint, newLevel uint) { - cfg := config.GetGuildConfig(ctx, e.GuildID) + cfg := config.GetGuild(ctx, e.GuildID) if cfg.FallbackChannel == 0 { return } diff --git a/migrations/000-leave-gorm.sql b/migrations/000-leave-gorm.sql index 360e2e3..e3aa71a 100644 --- a/migrations/000-leave-gorm.sql +++ b/migrations/000-leave-gorm.sql @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS xp_roles ( CREATE TABLE IF NOT EXISTS role_react_messages ( id SERIAL PRIMARY KEY, - message_id snowflake, + message_id snowflake UNIQUE, channel_id snowflake, guild_id snowflake REFERENCES guilds(id) ON DELETE CASCADE, note TEXT diff --git a/rolereact/manager.go b/rolereact/manager.go index 6455b97..a55aa5f 100644 --- a/rolereact/manager.go +++ b/rolereact/manager.go @@ -22,7 +22,7 @@ func MessageContent(message *config.RoleReactMessage) string { } for _, role := range message.Roles { if role.Reaction != "" && role.RoleID != 0 { - content += fmt.Sprintf("\n> -# %s <@&%s>", FormatEmoji(role.Reaction), role.RoleID) + content += fmt.Sprintf("\n> -# %s <@&%d>", FormatEmoji(role.Reaction), role.RoleID) } } if len(message.Roles) == 0 { @@ -114,16 +114,16 @@ func WaitForEmoji(ctx context.Context, dg bot.Session, userID, messageID uint64) } func GetMessageFromEditID(ctx context.Context, i *interaction.Interaction, editID uint) (*config.RoleReactMessage, bool) { - cfg := config.GetGuildConfig(ctx, i.GuildID) + cfg := config.GetGuild(ctx, i.GuildID) m, ok := messageEdits[editID] - if !ok || m.GuildConfigID != cfg.ID { + if !ok || m.GuildID != cfg.ID { return &config.RoleReactMessage{}, false } return m, true } func GetGuildConfigPreloaded(ctx context.Context, guildID uint64) *config.Guild { - cfg := config.Guild{GuildID: guildID} + cfg := config.Guild{ID: guildID} // err := oldGokord.DB.Where("guild_id = ?", cfg.GuildID).Preload("XpRoles").Preload("RrMessages.Roles").FirstOrCreate(cfg).Error /*err := common.GetDB(ctx).Where("guild_id = ?", cfg.GuildID).Preload("RrMessages.Roles").FirstOrCreate(&cfg).Error if err != nil { diff --git a/rolereact/views.go b/rolereact/views.go index 72a1ea0..b18ac86 100644 --- a/rolereact/views.go +++ b/rolereact/views.go @@ -97,7 +97,7 @@ func MessageModifyComponents(ctx context.Context, i *interaction.Interaction, pa &component.Button{ Label: "Message", Style: component.ButtonStyleLink, - URL: fmt.Sprintf("https://discord.com/channels/%s/%s/%s", message.GuildID, message.ChannelID, message.MessageID), + URL: fmt.Sprintf("https://discord.com/channels/%d/%d/%d", message.GuildID, message.ChannelID, message.MessageID), }, }, }}...) @@ -196,7 +196,7 @@ func MessageModifyRoleComponents(ctx context.Context, i *interaction.Interaction }, &component.Button{ Label: "Message", Style: component.ButtonStyleLink, - URL: fmt.Sprintf("https://discord.com/channels/%s/%s/%s", message.GuildID, message.ChannelID, message.MessageID), + URL: fmt.Sprintf("https://discord.com/channels/%d/%d/%d", message.GuildID, message.ChannelID, message.MessageID), }, }}, }...) diff --git a/user/level.go b/user/level.go index 26cf03f..d068f97 100644 --- a/user/level.go +++ b/user/level.go @@ -15,7 +15,7 @@ import ( ) func onNewLevel(ctx context.Context, dg bot.Session, m *user.Member, level uint) { - cfg := config.GetGuildConfig(ctx, m.GuildID) + cfg := config.GetGuild(ctx, m.GuildID) xpForLevel := exp.LevelXP(level) for _, role := range cfg.XpRoles { if role.XP <= xpForLevel && !slices.Contains(m.Roles, role.RoleID) { @@ -60,7 +60,7 @@ func PeriodicReducer(ctx context.Context, dg bot.Session) { n := 0 var wg sync.WaitGroup for _, g := range dg.GuildState().ListGuilds() { - cfg := config.GetGuildConfig(ctx, g) + cfg := config.GetGuild(ctx, g) res, err := common.GetDB(ctx).ExecContext( ctx, `DELETE FROM copaing_xps WHERE guild_id = ? and created_at < ?`, diff --git a/user/member.go b/user/member.go index 1e25617..3437be1 100644 --- a/user/member.go +++ b/user/member.go @@ -11,15 +11,15 @@ import ( type Copaing struct { ID uint64 - CopaingXPs []CopaingXP `gorm:"constraint:OnDelete:SET NULL;"` - GuildID uint64 `gorm:"not null"` + CopaingXPs []CopaingXP + GuildID uint64 lastSaved int } type CopaingXP struct { - XP uint `gorm:"default:0"` + XP uint CopaingID uint64 - GuildID uint64 `gorm:"not null;"` + GuildID uint64 CreatedAt time.Time } @@ -67,6 +67,7 @@ func (c *Copaing) load(ctx context.Context) error { `SELECT xp, created_at FROM copaing_xps WHERE copaing_id = ? AND guild_id = ?`, c.ID, c.GuildID, ) + defer rows.Close() if err != nil { if !errors.Is(err, sql.ErrNoRows) { return err |
