From 55befa3a53ab56bac31026b1b6099b2d31fd6d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 14:37:09 +0100 Subject: feat(state): base --- user/state.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 user/state.go diff --git a/user/state.go b/user/state.go new file mode 100644 index 0000000..bef2f53 --- /dev/null +++ b/user/state.go @@ -0,0 +1,80 @@ +package user + +import ( + "sync" + + "github.com/nyttikord/gokord/state" +) + +type CopaingCached struct { + ID uint `gorm:"primarykey"` + DiscordID string `gorm:"not null"` + GuildID string `gorm:"not null"` + XPs uint + XPToAdd uint +} + +const KeyCopaingCachedPrefix = "cc:" + +func KeyCopaingCached(c *Copaing) state.Key { + return KeyCopaingCachedRaw(c.GuildID, c.DiscordID) +} + +func KeyCopaingCachedRaw(guildID, copaingID string) state.Key { + return KeyCopaingCachedPrefix + state.Key(guildID+":"+copaingID) +} + +type State struct { + mu sync.RWMutex + storage state.MapStorage[CopaingCached] +} + +func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + c, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) + if err != nil { + return nil, err + } + mC := c.(CopaingCached) + return &mC, nil +} + +// CopaingAdd does not call Copaing.Load! +func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) error { + s.mu.Lock() + defer s.mu.Unlock() + + sum := calcXP(c) + var err error + var cc *CopaingCached + if cc, err = s.Copaing(c.GuildID, c.DiscordID); err != nil { + cc.XPs = sum + cc.XPToAdd = xpToAdd + } else { + cc = &CopaingCached{ + ID: c.ID, + DiscordID: c.DiscordID, + GuildID: c.GuildID, + XPs: sum, + XPToAdd: xpToAdd, + } + } + return s.storage.Write(KeyCopaingCached(c), *cc) +} + +func (s *State) CopaingRemove(c *Copaing) error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.storage.Delete(KeyCopaingCached(c)) +} + +func calcXP(c *Copaing) uint { + var sum uint + for _, entry := range c.CopaingXPs { + sum += entry.XP + } + return sum +} -- cgit v1.2.3 From febb77607e81fbb182dd456733ea5adafda44ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 16:31:25 +0100 Subject: perf(member): use stat for load --- commands/rank.go | 75 ++++++++++++++++++++---------------------- commands/reset.go | 48 ++++++++++++++------------- commands/stats.go | 97 ++++++++++++++++++++++++++++--------------------------- events.go | 22 ++++++------- justfile | 7 ++-- main.go | 12 ++++--- user/member.go | 40 +++++++++++++++++------ user/state.go | 69 ++++++++++++++++++++++++++++++--------- user/xp.go | 18 +++++------ 9 files changed, 226 insertions(+), 162 deletions(-) diff --git a/commands/rank.go b/commands/rank.go index 4080864..c65c758 100644 --- a/commands/rank.go +++ b/commands/rank.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "git.anhgelus.world/anhgelus/les-copaings-bot/exp" @@ -10,51 +11,45 @@ import ( "github.com/nyttikord/gokord/event" ) -func Rank(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { - c := user.GetCopaing(i.Member.User.ID, i.GuildID) // current user = member who used /rank - msg := "Votre niveau" - m := i.Member - var err error - if v, ok := optMap["copaing"]; ok { - u := v.UserValue(s.UserAPI()) - if u.Bot { - err = resp.SetMessage("Imagine si les bots avaient un niveau :rolling_eyes:").IsEphemeral().Send() - if err != nil { - s.Logger().Error("reply error user is a bot", "error", err) +func Rank(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { + return func(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { + c := user.GetCopaing(ctx, i.Member.User.ID, i.GuildID) // current user = member who used /rank + msg := "Votre niveau" + m := i.Member + var err error + if v, ok := optMap["copaing"]; ok { + u := v.UserValue(s.UserAPI()) + if u.Bot { + err = resp.SetMessage("Imagine si les bots avaient un niveau :rolling_eyes:").IsEphemeral().Send() + if err != nil { + s.Logger().Error("reply error user is a bot", "error", err) + } + return } - return - } - m, err = s.GuildAPI().Member(i.GuildID, u.ID) - if err != nil { - s.Logger().Error("fetching guild member", "error", err, "user", u.Username, "guild", i.GuildID) - err = resp.SetMessage("Erreur : impossible de récupérer le membre").IsEphemeral().Send() + m, err = s.GuildAPI().Member(i.GuildID, u.ID) if err != nil { - s.Logger().Error("reply error fetching guild member", "error", err) + s.Logger().Error("fetching guild member", "error", err, "user", u.Username, "guild", i.GuildID) + err = resp.SetMessage("Erreur : impossible de récupérer le membre").IsEphemeral().Send() + if err != nil { + s.Logger().Error("reply error fetching guild member", "error", err) + } + return } - return + c = user.GetCopaing(ctx, u.ID, i.GuildID) // current user = member targeted by member who wrote /rank + msg = fmt.Sprintf("Le niveau de %s", m.DisplayName()) } - c = user.GetCopaing(u.ID, i.GuildID) // current user = member targeted by member who wrote /rank - msg = fmt.Sprintf("Le niveau de %s", m.DisplayName()) - } - xp, err := c.GetXP(s.Logger()) - if err != nil { - s.Logger().Error("fetching xp", "error", err, "copaing", c.ID, "guild", i.GuildID) - err = resp.SetMessage("Erreur : impossible de récupérer l'XP").IsEphemeral().Send() + xp := c.XPs + lvl := exp.Level(xp) + nxtLvlXP := exp.LevelXP(lvl + 1) + err = resp.SetMessage(fmt.Sprintf( + "%s : **%d**\n> XP : %d\n> Prochain niveau dans %d XP", + msg, + lvl, + xp, + nxtLvlXP-xp, + )).Send() if err != nil { - s.Logger().Error("reply error fetching xp", "error", err) + s.Logger().Error("sending rank", "error", err) } - return - } - lvl := exp.Level(xp) - nxtLvlXP := exp.LevelXP(lvl + 1) - err = resp.SetMessage(fmt.Sprintf( - "%s : **%d**\n> XP : %d\n> Prochain niveau dans %d XP", - msg, - lvl, - xp, - nxtLvlXP-xp, - )).Send() - if err != nil { - s.Logger().Error("sending rank", "error", err) } } diff --git a/commands/reset.go b/commands/reset.go index 21bfeb7..56d3e44 100644 --- a/commands/reset.go +++ b/commands/reset.go @@ -1,6 +1,8 @@ package commands import ( + "context" + "git.anhgelus.world/anhgelus/les-copaings-bot/user" "github.com/anhgelus/gokord" "github.com/anhgelus/gokord/cmd" @@ -16,31 +18,33 @@ func Reset(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd } } -func ResetUser(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { - resp.IsEphemeral() - v, ok := optMap["user"] - if !ok { - if err := resp.SetMessage("Le user n'a pas été renseigné.").Send(); err != nil { - s.Logger().Error("sending error copaing not set", "error", err) +func ResetUser(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { + return func(s bot.Session, i *event.InteractionCreate, optMap cmd.OptionMap, resp *cmd.ResponseBuilder) { + resp.IsEphemeral() + v, ok := optMap["user"] + if !ok { + if err := resp.SetMessage("Le user n'a pas été renseigné.").Send(); err != nil { + s.Logger().Error("sending error copaing not set", "error", err) + } + return } - return - } - m := v.UserValue(s.UserAPI()) - if m.Bot { - if err := resp.SetMessage("Les bots n'ont pas de niveau :upside_down:").Send(); err != nil { - s.Logger().Error("sending error bot does not have xp", "error", err) + m := v.UserValue(s.UserAPI()) + if m.Bot { + if err := resp.SetMessage("Les bots n'ont pas de niveau :upside_down:").Send(); err != nil { + s.Logger().Error("sending error bot does not have xp", "error", err) + } + return } - return - } - err := user.GetCopaing(m.ID, i.GuildID).Delete() - if err != nil { - s.Logger().Error("deleting copaing", "error", err, "user", m.Username, "guild", i.GuildID) - err = resp.SetMessage("Erreur : impossible de reset l'utilisateur").Send() + err := user.GetCopaing(ctx, m.ID, i.GuildID).Copaing(ctx).Delete(ctx) if err != nil { - s.Logger().Error("sending error while deleting", "error", err) + s.Logger().Error("deleting copaing", "error", err, "user", m.Username, "guild", i.GuildID) + err = resp.SetMessage("Erreur : impossible de reset l'utilisateur").Send() + if err != nil { + s.Logger().Error("sending error while deleting", "error", err) + } + } + if err = resp.SetMessage("Le user bien été reset.").Send(); err != nil { + s.Logger().Error("sending reset success", "error", err) } - } - if err = resp.SetMessage("Le user bien été reset.").Send(); err != nil { - s.Logger().Error("sending reset success", "error", err) } } diff --git a/commands/stats.go b/commands/stats.go index 4fc35ae..716616d 100644 --- a/commands/stats.go +++ b/commands/stats.go @@ -2,6 +2,7 @@ package commands import ( "bytes" + "context" "errors" "fmt" "image/color" @@ -46,56 +47,58 @@ var colors = []color.RGBA{ {193, 18, 31, 255}, } -func Stats(s bot.Session, i *event.InteractionCreate, opt cmd.OptionMap, resp *cmd.ResponseBuilder) { - cfg := config.GetGuildConfig(i.GuildID) - days := 15 - if gokord.Debug { - days = 90 - } - if v, ok := opt["days"]; ok { - in := v.IntValue() - if in < 1 || uint(in) > cfg.DaysXPRemains { - msg := fmt.Sprintf("Nombre de jours invalide. Il doit être strictement positif et inférieur à %d", cfg.DaysXPRemains) - if err := resp.SetMessage(msg).IsEphemeral().Send(); err != nil { - s.Logger().Error("sending error invalid days", "error", err) - } - return +func Stats(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, opt cmd.OptionMap, resp *cmd.ResponseBuilder) { + return func(s bot.Session, i *event.InteractionCreate, opt cmd.OptionMap, resp *cmd.ResponseBuilder) { + cfg := config.GetGuildConfig(i.GuildID) + days := 15 + if gokord.Debug { + days = 90 } - days = int(in) - } - err := resp.IsDeferred().Send() - if err != nil { - s.Logger().Error("sending deferred", "error", err) - return - } - go func() { - var w io.WriterTo - if v, ok := opt["user"]; ok { - w, err = statsMember(s, i, days, v.UserValue(s.UserAPI()).ID) - } else { - w, err = statsAll(s, i, days) - } - if err != nil { - s.Logger().Error("generating stats", "error", err, "guild", i.GuildID) - if err = resp.IsEphemeral().SetMessage("Il y a eu une erreur...").Send(); err != nil { - s.Logger().Error("sending error occurred", "error", err) + if v, ok := opt["days"]; ok { + in := v.IntValue() + if in < 1 || uint(in) > cfg.DaysXPRemains { + msg := fmt.Sprintf("Nombre de jours invalide. Il doit être strictement positif et inférieur à %d", cfg.DaysXPRemains) + if err := resp.SetMessage(msg).IsEphemeral().Send(); err != nil { + s.Logger().Error("sending error invalid days", "error", err) + } + return } - return - } - b := new(bytes.Buffer) - _, err = w.WriteTo(b) - if err != nil { - s.Logger().Error("writing png", "error", err) + days = int(in) } - err = resp.AddFile(&channel.File{ - Name: "plot.png", - ContentType: "image/png", - Reader: b, - }).Send() + err := resp.IsDeferred().Send() if err != nil { - s.Logger().Error("sending stats", "error", err) + s.Logger().Error("sending deferred", "error", err) + return } - }() + go func() { + var w io.WriterTo + if v, ok := opt["user"]; ok { + w, err = statsMember(ctx, s, i, days, v.UserValue(s.UserAPI()).ID) + } else { + w, err = statsAll(s, i, days) + } + if err != nil { + s.Logger().Error("generating stats", "error", err, "guild", i.GuildID) + if err = resp.IsEphemeral().SetMessage("Il y a eu une erreur...").Send(); err != nil { + s.Logger().Error("sending error occurred", "error", err) + } + return + } + b := new(bytes.Buffer) + _, err = w.WriteTo(b) + if err != nil { + s.Logger().Error("writing png", "error", err) + } + err = resp.AddFile(&channel.File{ + Name: "plot.png", + ContentType: "image/png", + Reader: b, + }).Send() + if err != nil { + s.Logger().Error("sending stats", "error", err) + } + }() + } } func statsAll(s bot.Session, i *event.InteractionCreate, days int) (io.WriterTo, error) { @@ -104,7 +107,7 @@ func statsAll(s bot.Session, i *event.InteractionCreate, days int) (io.WriterTo, }) } -func statsMember(s bot.Session, i *event.InteractionCreate, days int, discordID string) (io.WriterTo, error) { +func statsMember(ctx context.Context, s bot.Session, i *event.InteractionCreate, days int, discordID string) (io.WriterTo, error) { _, err := s.GuildAPI().Member(i.GuildID, discordID) if err != nil { return nil, err @@ -112,7 +115,7 @@ func statsMember(s bot.Session, i *event.InteractionCreate, days int, discordID return stats(s, i, days, func(before, after string) *gorm.DB { return gokord.DB.Raw( before+"WHERE guild_id = ? and created_at > ? and copaing_id = ?"+after, - i.GuildID, exp.TimeStampNDaysBefore(uint(days)), user.GetCopaing(discordID, i.GuildID).ID, + i.GuildID, exp.TimeStampNDaysBefore(uint(days)), user.GetCopaing(ctx, discordID, i.GuildID).ID, ) }) } diff --git a/events.go b/events.go index 5191120..bdf8c77 100644 --- a/events.go +++ b/events.go @@ -24,7 +24,7 @@ var ( connectedSince = map[string]int64{} ) -func OnMessage(_ context.Context, s bot.Session, m *event.MessageCreate) { +func OnMessage(ctx context.Context, s bot.Session, m *event.MessageCreate) { if m.Author.Bot { return } @@ -32,13 +32,13 @@ func OnMessage(_ context.Context, s bot.Session, m *event.MessageCreate) { if cfg.IsDisabled(s, m.ChannelID) { return } - c := user.GetCopaing(m.Author.ID, m.GuildID) + cc := user.GetCopaing(ctx, m.Author.ID, m.GuildID) // add exp trimmed := exp.TrimMessage(strings.ToLower(m.Content)) m.Member.User = m.Author m.Member.GuildID = m.GuildID xp := min(exp.MessageXP(uint(len(trimmed)), exp.CalcDiversity(trimmed)), MaxXpPerMessage) - c.AddXP(s, m.Member, xp, func(_ uint, _ uint) { + cc.AddXP(ctx, s, m.Member, xp, func(_ uint, _ uint) { if err := s.ChannelAPI().MessageReactionAdd(m.ChannelID, m.Message.ID, "⬆"); err != nil { s.Logger().Error( "add reaction for new level", @@ -50,7 +50,7 @@ func OnMessage(_ context.Context, s bot.Session, m *event.MessageCreate) { }) } -func OnVoiceUpdate(_ context.Context, s bot.Session, e *event.VoiceStateUpdate) { +func OnVoiceUpdate(ctx context.Context, s bot.Session, e *event.VoiceStateUpdate) { if e.Member.User.Bot { return } @@ -64,7 +64,7 @@ func OnVoiceUpdate(_ context.Context, s bot.Session, e *event.VoiceStateUpdate) if cfg.IsDisabled(s, e.BeforeUpdate.ChannelID) { return } - onDisconnect(s, e) + onDisconnect(ctx, s, e) } } @@ -77,9 +77,9 @@ func onConnection(s bot.Session, e *event.VoiceStateUpdate) { connectedSince[genMapKey(e.GuildID, e.UserID)] = time.Now().Unix() } -func onDisconnect(s bot.Session, e *event.VoiceStateUpdate) { +func onDisconnect(ctx context.Context, s bot.Session, e *event.VoiceStateUpdate) { now := time.Now().Unix() - c := user.GetCopaing(e.UserID, e.GuildID) + cc := user.GetCopaing(ctx, e.UserID, e.GuildID) // check the validity of user con, ok := connectedSince[genMapKey(e.GuildID, e.UserID)] if !ok || con == NotConnected { @@ -99,7 +99,7 @@ func onDisconnect(s bot.Session, e *event.VoiceStateUpdate) { } timeInVocal = min(timeInVocal, MaxTimeInVocal) e.Member.GuildID = e.GuildID - c.AddXP(s, e.Member, exp.VocalXP(uint(timeInVocal)), func(_ uint, newLevel uint) { + cc.AddXP(ctx, s, e.Member, exp.VocalXP(uint(timeInVocal)), func(_ uint, newLevel uint) { cfg := config.GetGuildConfig(e.GuildID) if len(cfg.FallbackChannel) == 0 { return @@ -113,12 +113,12 @@ func onDisconnect(s bot.Session, e *event.VoiceStateUpdate) { }) } -func OnLeave(_ context.Context, s bot.Session, e *event.GuildMemberRemove) { +func OnLeave(ctx context.Context, s bot.Session, e *event.GuildMemberRemove) { s.Logger().Debug("leave event", "user", e.User.Username) if e.User.Bot { return } - c := user.GetCopaing(e.User.ID, e.GuildID) + c := user.GetCopaing(ctx, e.User.ID, e.GuildID).Copaing(ctx) err := gokord.DB. Where("copaing_id = ? and guild_id = ?", c.ID, e.GuildID). Delete(&user.CopaingXP{}). @@ -126,7 +126,7 @@ func OnLeave(_ context.Context, s bot.Session, e *event.GuildMemberRemove) { if err != nil { s.Logger().Error("deleting user xp from DB", "user", e.User.Username, "guild", e.GuildID) } - if err = c.Delete(); err != nil { + if err = c.Delete(ctx); err != nil { s.Logger().Error("deleting user from DB", "user", e.User.Username, "guild", e.GuildID) } } diff --git a/justfile b/justfile index ab58ac1..4e0c917 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,8 @@ -dev: +dev: stop podman network create db podman run -p 5432:5432 --rm --network db --name postgres --env-file .env -v ./data:/var/lib/postgresql/data -d postgres:alpine podman run -p 8080:8080 --rm --network db --name adminer -d adminer + sleep 5 go run . update: @@ -11,11 +12,11 @@ update: go run . stop: - podman stop postgres adminer + podman stop postgres adminer || (echo "no container"; exit 0) podman network rm db clean-network: - podman network rm db + podman network rm db || echo "no network" build: GOAMD64=v3 go build -ldflags "-s" . diff --git a/main.go b/main.go index d2e6c01..21b372b 100644 --- a/main.go +++ b/main.go @@ -86,13 +86,15 @@ func main() { adm := gokord.AdminPermission + ctx := user.SetState(context.Background(), user.NewState()) + rankCmd := cmd.New("rank", "Affiche le niveau d'un copaing"). AddOption(cmd.NewOption( types.CommandOptionUser, "copaing", "Le niveau du Copaing que vous souhaitez obtenir", )). - SetHandler(commands.Rank) + SetHandler(commands.Rank(ctx)) configCmd := cmd.New("config", "Modifie la config"). SetPermission(&adm). @@ -111,7 +113,7 @@ func main() { "user", "Copaing a reset", ).IsRequired()). - SetHandler(commands.ResetUser). + SetHandler(commands.ResetUser(ctx)). SetPermission(&adm) creditsCmd := cmd.New("credits", "Crédits"). @@ -128,7 +130,7 @@ func main() { "user", "Utilisateur à inspecter", )). - SetHandler(commands.Stats) + SetHandler(commands.Stats(ctx)) rolereactCmd := cmd.New("rolereact", "Envoie un message permettant de récupérer des rôles grâce à des réactions"). SetPermission(&adm). @@ -182,7 +184,7 @@ func main() { user.PeriodicReducer(dg) - stopPeriodicReducer = gokord.NewTimer(d, func(stop chan<- interface{}) { + stopPeriodicReducer = gokord.NewTimer(d, func(stop chan<- any) { dg.Logger().Debug("periodic reducer") user.PeriodicReducer(dg) }) @@ -279,7 +281,7 @@ func main() { b.AddHandler(OnVoiceUpdate) b.AddHandler(OnLeave) - b.Start(context.Background()) + b.Start(ctx) if stopPeriodicReducer != nil { stopPeriodicReducer <- true diff --git a/user/member.go b/user/member.go index 9068a6f..690f7c5 100644 --- a/user/member.go +++ b/user/member.go @@ -1,6 +1,7 @@ package user import ( + "context" "time" "github.com/anhgelus/gokord" @@ -26,26 +27,47 @@ type CopaingAccess interface { GetXP() uint } -func GetCopaing(discordID string, guildID string) *Copaing { - c := Copaing{DiscordID: discordID, GuildID: guildID} - if err := c.Load(); err != nil { - panic(err) +func GetCopaing(ctx context.Context, discordID string, guildID string) *CopaingCached { + state := GetState(ctx) + cc, err := state.Copaing(guildID, discordID) + if err != nil { + c := Copaing{DiscordID: discordID, GuildID: guildID} + if err := c.Load(ctx); err != nil { + panic(err) + } + cc = FromCopaing(&c) } - return &c + return cc } -func (c *Copaing) Load() error { - return gokord.DB. +func (c *Copaing) Load(ctx context.Context) error { + err := gokord.DB. Where("discord_id = ? and guild_id = ?", c.DiscordID, c.GuildID). Preload("CopaingXPs"). FirstOrCreate(c). Error + if err != nil { + return err + } + state := GetState(ctx) + _, err = state.CopaingAdd(c, 0) + return err } -func (c *Copaing) Save() error { +func (c *Copaing) Save(ctx context.Context) error { + state := GetState(ctx) + _, err := state.CopaingAdd(c, 0) + if err != nil { + return err + } return gokord.DB.Save(c).Error } -func (c *Copaing) Delete() error { +func (c *Copaing) Delete(ctx context.Context) error { + state := GetState(ctx) + err := state.CopaingRemove(c) + if err != nil { + return err + } return gokord.DB.Where("guild_id = ? AND discord_id = ?", c.GuildID, c.DiscordID).Delete(c).Error } diff --git a/user/state.go b/user/state.go index bef2f53..07096db 100644 --- a/user/state.go +++ b/user/state.go @@ -1,6 +1,7 @@ package user import ( + "context" "sync" "github.com/nyttikord/gokord/state" @@ -14,6 +15,35 @@ type CopaingCached struct { XPToAdd uint } +// Copaing turns a CopaingCached into a Copaing. +// This operation is heavy. +func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { + c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} + if err := c.Load(ctx); err != nil { + panic(err) + } + return &c +} + +func (cc *CopaingCached) Save(ctx context.Context) error { + state := GetState(ctx) + + state.mu.Lock() + defer state.mu.Unlock() + + return state.storage.Write(KeyCopaingCachedRaw(cc.GuildID, cc.DiscordID), *cc) +} + +func FromCopaing(c *Copaing) *CopaingCached { + return &CopaingCached{ + ID: c.ID, + DiscordID: c.DiscordID, + GuildID: c.GuildID, + XPs: calcXP(c), + XPToAdd: 0, + } +} + const KeyCopaingCachedPrefix = "cc:" func KeyCopaingCached(c *Copaing) state.Key { @@ -29,6 +59,22 @@ type State struct { storage state.MapStorage[CopaingCached] } +func NewState() *State { + return &State{ + storage: state.MapStorage[CopaingCached]{}, + } +} + +const ContextKeyState = "state" + +func GetState(ctx context.Context) *State { + return ctx.Value(ContextKeyState).(*State) +} + +func SetState(ctx context.Context, state *State) context.Context { + return context.WithValue(ctx, ContextKeyState, state) +} + func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -42,26 +88,19 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { } // CopaingAdd does not call Copaing.Load! -func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) error { - s.mu.Lock() - defer s.mu.Unlock() - - sum := calcXP(c) +func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { var err error var cc *CopaingCached - if cc, err = s.Copaing(c.GuildID, c.DiscordID); err != nil { - cc.XPs = sum + if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { + cc.XPs = calcXP(c) cc.XPToAdd = xpToAdd } else { - cc = &CopaingCached{ - ID: c.ID, - DiscordID: c.DiscordID, - GuildID: c.GuildID, - XPs: sum, - XPToAdd: xpToAdd, - } + cc = FromCopaing(c) } - return s.storage.Write(KeyCopaingCached(c), *cc) + s.mu.Lock() + defer s.mu.Unlock() + + return cc, s.storage.Write(KeyCopaingCached(c), *cc) } func (s *State) CopaingRemove(c *Copaing) error { diff --git a/user/xp.go b/user/xp.go index dbca9de..eceb673 100644 --- a/user/xp.go +++ b/user/xp.go @@ -1,6 +1,7 @@ package user import ( + "context" "log/slog" "slices" "sync" @@ -25,17 +26,14 @@ func (c *cXP) GetXP() uint { return c.Cxp } -func (c *Copaing) AddXP(s bot.Session, m *user.Member, xp uint, fn func(uint, uint)) { - old, err := c.GetXP(s.Logger()) - if err != nil { - s.Logger().Error("getting xp", "error", err, "user", m.DisplayName(), "guild", c.GuildID) - return - } +func (cc *CopaingCached) AddXP(ctx context.Context, s bot.Session, m *user.Member, xp uint, fn func(uint, uint)) { + old := cc.XPs pastLevel := exp.Level(old) s.Logger().Debug("adding xp", "user", m.DisplayName(), "old", old, "to add", xp) - c.CopaingXPs = append(c.CopaingXPs, CopaingXP{CopaingID: c.ID, XP: xp, GuildID: c.GuildID}) - if err = c.Save(); err != nil { - s.Logger().Error("saving user", "error", err, "user", m.DisplayName(), "xp", xp, "guild", c.GuildID) + cc.XPs += xp + cc.XPToAdd += xp + if err := cc.Save(ctx); err != nil { + s.Logger().Error("saving user in state", "error", err, "user", m.DisplayName(), "xp", xp, "guild", cc.GuildID) return } newLevel := exp.Level(old + xp) @@ -91,7 +89,7 @@ func GetBestXP(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAcc } defer rows.Close() var l []*cXP - wg := sync.WaitGroup{} + var wg sync.WaitGroup for rows.Next() { var c Copaing err = gokord.DB.ScanRows(rows, &c) -- cgit v1.2.3 From 64dfe4ed79022c6a7a00991db7ba679f2dcb3495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 17:06:38 +0100 Subject: refactor(member): better distinction between cached and from database --- commands/reset.go | 3 ++- commands/top.go | 2 +- events.go | 14 +++----------- user/level.go | 9 +++++---- user/member.go | 17 +++++++---------- user/state.go | 38 ++++++++++++++++++++++++++++++++++---- user/xp.go | 18 ++++++------------ 7 files changed, 58 insertions(+), 43 deletions(-) diff --git a/commands/reset.go b/commands/reset.go index 56d3e44..ae31781 100644 --- a/commands/reset.go +++ b/commands/reset.go @@ -12,6 +12,7 @@ import ( func Reset(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { var copaings []*user.Copaing + //TODO: delete everything from cache gokord.DB.Where("guild_id = ?", i.GuildID).Delete(&copaings) if err := resp.IsEphemeral().SetMessage("L'XP a été reset.").Send(); err != nil { s.Logger().Error("sending reset success", "error", err) @@ -35,7 +36,7 @@ func ResetUser(ctx context.Context) func(s bot.Session, i *event.InteractionCrea } return } - err := user.GetCopaing(ctx, m.ID, i.GuildID).Copaing(ctx).Delete(ctx) + err := user.GetCopaing(ctx, m.ID, i.GuildID).Delete(ctx) if err != nil { s.Logger().Error("deleting copaing", "error", err, "user", m.Username, "guild", i.GuildID) err = resp.SetMessage("Erreur : impossible de reset l'utilisateur").Send() diff --git a/commands/top.go b/commands/top.go index bb08144..bd92a28 100644 --- a/commands/top.go +++ b/commands/top.go @@ -68,7 +68,7 @@ func Top(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.R func genTopsMessage(tops []user.CopaingAccess) string { msg := "" for i, c := range tops { - msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.ToCopaing().DiscordID, exp.Level(c.GetXP())) + msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.Copaing().DiscordID, exp.Level(c.GetXP())) if i != len(tops)-1 { msg += "\n" } diff --git a/events.go b/events.go index bdf8c77..839fa6c 100644 --- a/events.go +++ b/events.go @@ -9,7 +9,6 @@ import ( "git.anhgelus.world/anhgelus/les-copaings-bot/config" "git.anhgelus.world/anhgelus/les-copaings-bot/exp" "git.anhgelus.world/anhgelus/les-copaings-bot/user" - "github.com/anhgelus/gokord" "github.com/nyttikord/gokord/bot" "github.com/nyttikord/gokord/event" ) @@ -118,15 +117,8 @@ func OnLeave(ctx context.Context, s bot.Session, e *event.GuildMemberRemove) { if e.User.Bot { return } - c := user.GetCopaing(ctx, e.User.ID, e.GuildID).Copaing(ctx) - err := gokord.DB. - Where("copaing_id = ? and guild_id = ?", c.ID, e.GuildID). - Delete(&user.CopaingXP{}). - Error - if err != nil { - s.Logger().Error("deleting user xp from DB", "user", e.User.Username, "guild", e.GuildID) - } - if err = c.Delete(ctx); err != nil { - s.Logger().Error("deleting user from DB", "user", e.User.Username, "guild", e.GuildID) + c := user.GetCopaing(ctx, e.User.ID, e.GuildID) + if err := c.Delete(ctx); err != nil { + s.Logger().Error("deleting user", "user", e.User.Username, "guild", e.GuildID) } } diff --git a/user/level.go b/user/level.go index e7b96af..654ecd1 100644 --- a/user/level.go +++ b/user/level.go @@ -76,7 +76,7 @@ func PeriodicReducer(s *discordgo.Session) { } cxps[i] = &cXP{ Cxp: xp, - Copaing: c, + copaing: c, } }() } @@ -105,13 +105,14 @@ func PeriodicReducer(s *discordgo.Session) { time.Sleep(15 * time.Second) // prevents spamming the API } oldXp := c.GetXP() - xp, err := c.ToCopaing().GetXP(s.Logger()) + cp := c.Copaing() + xp, err := cp.GetXP(s.Logger()) if err != nil { - s.Logger().Error("getting xp of copaing", "error", err, "copaing", c.ID, "guild", c.GuildID) + s.Logger().Error("getting xp of copaing", "error", err, "copaing", cp.ID, "guild", cp.GuildID) continue } if exp.Level(oldXp) != exp.Level(xp) { - c.OnNewLevel(s, exp.Level(xp)) + cp.OnNewLevel(s, exp.Level(xp)) } } s.Logger().Debug("periodic reduce finished", "guilds affected", i) diff --git a/user/member.go b/user/member.go index 690f7c5..9c9ad1f 100644 --- a/user/member.go +++ b/user/member.go @@ -23,7 +23,7 @@ type CopaingXP struct { } type CopaingAccess interface { - ToCopaing() *Copaing + Copaing() *Copaing GetXP() uint } @@ -54,18 +54,15 @@ func (c *Copaing) Load(ctx context.Context) error { return err } -func (c *Copaing) Save(ctx context.Context) error { - state := GetState(ctx) - _, err := state.CopaingAdd(c, 0) - if err != nil { - return err - } +func (c *Copaing) Save() error { return gokord.DB.Save(c).Error } -func (c *Copaing) Delete(ctx context.Context) error { - state := GetState(ctx) - err := state.CopaingRemove(c) +func (c *Copaing) Delete() error { + err := gokord.DB. + Where("copaing_id = ? and guild_id = ?", c.ID, c.GuildID). + Delete(&CopaingXP{}). + Error if err != nil { return err } diff --git a/user/state.go b/user/state.go index 07096db..84f2852 100644 --- a/user/state.go +++ b/user/state.go @@ -15,9 +15,9 @@ type CopaingCached struct { XPToAdd uint } -// Copaing turns a CopaingCached into a Copaing. +// copaing turns a CopaingCached into a Copaing. // This operation is heavy. -func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { +func (cc *CopaingCached) copaing(ctx context.Context) *Copaing { c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} if err := c.Load(ctx); err != nil { panic(err) @@ -25,6 +25,15 @@ func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { return &c } +func (cc *CopaingCached) Sync(ctx context.Context) error { + synced, err := GetState(ctx).CopaingAdd(cc.copaing(ctx), cc.XPToAdd) + if err != nil { + return err + } + *cc = *synced + return nil +} + func (cc *CopaingCached) Save(ctx context.Context) error { state := GetState(ctx) @@ -34,6 +43,26 @@ func (cc *CopaingCached) Save(ctx context.Context) error { return state.storage.Write(KeyCopaingCachedRaw(cc.GuildID, cc.DiscordID), *cc) } +func (cc *CopaingCached) SaveInDB(ctx context.Context) error { + c := cc.copaing(ctx) + c.CopaingXPs = append(c.CopaingXPs, CopaingXP{CopaingID: c.ID, XP: cc.XPToAdd, GuildID: c.GuildID}) + err := c.Save() + if err != nil { + return err + } + cc.XPToAdd = 0 + return cc.Save(ctx) +} + +func (cc *CopaingCached) Delete(ctx context.Context) error { + c := cc.copaing(ctx) + err := c.Delete() + if err != nil { + return err + } + return GetState(ctx).CopaingRemove(c) +} + func FromCopaing(c *Copaing) *CopaingCached { return &CopaingCached{ ID: c.ID, @@ -92,11 +121,12 @@ func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { var err error var cc *CopaingCached if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { - cc.XPs = calcXP(c) - cc.XPToAdd = xpToAdd + cc.XPs = calcXP(c) + xpToAdd } else { cc = FromCopaing(c) } + cc.XPToAdd = xpToAdd + s.mu.Lock() defer s.mu.Unlock() diff --git a/user/xp.go b/user/xp.go index eceb673..246b097 100644 --- a/user/xp.go +++ b/user/xp.go @@ -14,12 +14,12 @@ import ( ) type cXP struct { - Cxp uint - *Copaing + Cxp uint + copaing *Copaing } -func (c *cXP) ToCopaing() *Copaing { - return c.Copaing +func (c *cXP) Copaing() *Copaing { + return c.copaing } func (c *cXP) GetXP() uint { @@ -105,19 +105,13 @@ func GetBestXP(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAcc logger.Error("fetching xp", "error", err, "copaing", c.ID, "guild", c.GuildID) return } - l = append(l, &cXP{Cxp: xp, Copaing: &c}) + l = append(l, &cXP{Cxp: xp, copaing: &c}) }() } wg.Wait() slices.SortFunc(l, func(a, b *cXP) int { // desc order - if a.Cxp < b.Cxp { - return 1 - } - if a.Cxp > b.Cxp { - return -1 - } - return 0 + return int(b.Cxp) - int(a.Cxp) }) m := min(len(l), int(n)) cs := make([]CopaingAccess, m) -- cgit v1.2.3 From c9129c2e7edcf6e588cac674dfdb240f1714083d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 19:15:12 +0100 Subject: feat(member): save cache in db every 30 minutes --- go.mod | 4 +-- main.go | 29 +++++++++++------- user/level.go | 96 ++++++++++++++++++++++++++++------------------------------ user/member.go | 6 ++-- user/state.go | 91 +++++++++++++++++++++++++++++++++++-------------------- user/xp.go | 6 ++-- 6 files changed, 127 insertions(+), 105 deletions(-) diff --git a/go.mod b/go.mod index 7f2c6cd..350a131 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module git.anhgelus.world/anhgelus/les-copaings-bot -go 1.24.0 - -toolchain go1.24.6 +go 1.25.0 require ( github.com/anhgelus/gokord v0.13.2-0.20251025205525-12213d3f60bc diff --git a/main.go b/main.go index 21b372b..0d5bcd3 100644 --- a/main.go +++ b/main.go @@ -40,7 +40,8 @@ var ( } verbose bool - stopPeriodicReducer chan<- interface{} + stopPeriodicReducer chan<- any + stopPeriodicSaver chan<- any ) //go:embed assets/inter-variable.ttf @@ -60,13 +61,10 @@ func init() { panic(err) } inter := font.Font{Typeface: "Inter"} - font.DefaultCache.Add( - []font.Face{ - { - Font: inter, - Face: fontTTF, - }, - }) + font.DefaultCache.Add([]font.Face{{ + Font: inter, + Face: fontTTF, + }}) plot.DefaultFont = inter } @@ -181,12 +179,18 @@ func main() { if gokord.Debug { d = 3 * exp.DebugFactor * time.Second } + d2 := 30 * time.Minute + if gokord.Debug { + d2 = 1 * exp.DebugFactor * time.Second + } - user.PeriodicReducer(dg) + user.PeriodicReducer(ctx, dg) stopPeriodicReducer = gokord.NewTimer(d, func(stop chan<- any) { - dg.Logger().Debug("periodic reducer") - user.PeriodicReducer(dg) + user.PeriodicReducer(ctx, dg) + }) + stopPeriodicSaver = gokord.NewTimer(d2, func(c chan<- any) { + user.PeriodicSaver(ctx, dg) }) }, Innovations: innovations, @@ -223,6 +227,9 @@ func main() { } s.Logger().Debug("pushed rolereaction message command", "CommandID", c.ID) }) + b.AddHandler(func(ct context.Context, s bot.Session, _ *event.Disconnect) { + user.PeriodicSaver(ctx, s) + }) b.AddHandler(func(_ context.Context, s bot.Session, i *event.InteractionCreate) { if i.Type != types.InteractionApplicationCommand { return diff --git a/user/level.go b/user/level.go index 654ecd1..07df16f 100644 --- a/user/level.go +++ b/user/level.go @@ -1,6 +1,7 @@ package user import ( + "context" "slices" "sync" "time" @@ -45,7 +46,7 @@ func onNewLevel(s bot.Session, m *user.Member, level uint) { } } -func (c *Copaing) OnNewLevel(s *discordgo.Session, level uint) { +func (c *CopaingCached) onNewLevel(s *discordgo.Session, level uint) { m, err := s.GuildAPI().Member(c.GuildID, c.DiscordID) if err != nil { s.Logger().Error("getting member for new level", "error", err, "user", c.DiscordID, "guild", c.GuildID) @@ -54,66 +55,61 @@ func (c *Copaing) OnNewLevel(s *discordgo.Session, level uint) { onNewLevel(s, m, level) } -func PeriodicReducer(s *discordgo.Session) { - wg := &sync.WaitGroup{} - var cs []*Copaing - if err := gokord.DB.Find(&cs).Error; err != nil { - s.Logger().Error("fetching all copaings", "error", err) - return - } - cxps := make([]*cXP, len(cs)) - for i, c := range cs { - if i%25 == 24 { - wg.Wait() // prevents spamming the DB - } - wg.Add(1) - go func() { - defer wg.Done() - xp, err := c.GetXP(s.Logger()) - if err != nil { - s.Logger().Error("getting xp", "error", err, "copaing", c.ID, "guild", c.GuildID) - xp = 0 - } - cxps[i] = &cXP{ - Cxp: xp, - copaing: c, - } - }() - } - wg.Wait() - i := 0 +func PeriodicReducer(ctx context.Context, s *discordgo.Session) { + PeriodicSaver(ctx, s) + + s.Logger().Debug("periodic reducer") + + state := GetState(ctx) + + n := 0 + var wg sync.WaitGroup for _, g := range s.GuildAPI().State.Guilds() { - i++ - wg.Add(1) - go func() { - defer wg.Done() - cfg := config.GetGuildConfig(g) - res := gokord.DB. - Model(&CopaingXP{}). - Where("guild_id = ? and created_at < ?", g, exp.TimeStampNDaysBefore(cfg.DaysXPRemains)). - Delete(&CopaingXP{}) - if res.Error != nil { - s.Logger().Error("removing old xp", "error", res.Error, "guild", g) - } - s.Logger().Debug("guild cleaned", "guild", g, "rows affected", res.RowsAffected) - }() + n++ + cfg := config.GetGuildConfig(g) + res := gokord.DB. + Model(&CopaingXP{}). + Where("guild_id = ? and created_at < ?", g, exp.TimeStampNDaysBefore(cfg.DaysXPRemains)). + Delete(&CopaingXP{}) + if res.Error != nil { + s.Logger().Error("removing old xp", "error", res.Error, "guild", g) + continue + } + s.Logger().Debug("guild cleaned", "guild", g, "rows affected", res.RowsAffected) + + wg.Go(func() { + syncCopaings(ctx, s, state.Copaings(g)) + }) } + wg.Wait() - for i, c := range cxps { + + s.Logger().Debug("periodic reduce finished", "guilds affected", n) +} + +func syncCopaings(ctx context.Context, s *discordgo.Session, ccs []CopaingCached) { + for i, cc := range ccs { if i%50 == 49 { s.Logger().Debug("sleeping...") time.Sleep(15 * time.Second) // prevents spamming the API } - oldXp := c.GetXP() - cp := c.Copaing() - xp, err := cp.GetXP(s.Logger()) + oldXp := cc.XPs + err := cc.Sync(ctx) if err != nil { - s.Logger().Error("getting xp of copaing", "error", err, "copaing", cp.ID, "guild", cp.GuildID) + s.Logger().Error("syncing copaing", "error", err, "copaing", cc.ID, "guild", cc.GuildID) continue } + xp := cc.XPs if exp.Level(oldXp) != exp.Level(xp) { - cp.OnNewLevel(s, exp.Level(xp)) + cc.onNewLevel(s, exp.Level(xp)) } } - s.Logger().Debug("periodic reduce finished", "guilds affected", i) +} + +func PeriodicSaver(ctx context.Context, s bot.Session) { + s.Logger().Debug("saving state in DB") + err := saveStateInDB(ctx) + if err != nil { + panic(err) + } } diff --git a/user/member.go b/user/member.go index 9c9ad1f..4969f8a 100644 --- a/user/member.go +++ b/user/member.go @@ -32,7 +32,7 @@ func GetCopaing(ctx context.Context, discordID string, guildID string) *CopaingC cc, err := state.Copaing(guildID, discordID) if err != nil { c := Copaing{DiscordID: discordID, GuildID: guildID} - if err := c.Load(ctx); err != nil { + if err := c.load(); err != nil { panic(err) } cc = FromCopaing(&c) @@ -40,7 +40,7 @@ func GetCopaing(ctx context.Context, discordID string, guildID string) *CopaingC return cc } -func (c *Copaing) Load(ctx context.Context) error { +func (c *Copaing) load() error { err := gokord.DB. Where("discord_id = ? and guild_id = ?", c.DiscordID, c.GuildID). Preload("CopaingXPs"). @@ -49,8 +49,6 @@ func (c *Copaing) Load(ctx context.Context) error { if err != nil { return err } - state := GetState(ctx) - _, err = state.CopaingAdd(c, 0) return err } diff --git a/user/state.go b/user/state.go index 84f2852..540d496 100644 --- a/user/state.go +++ b/user/state.go @@ -2,31 +2,46 @@ package user import ( "context" + "errors" "sync" + "time" "github.com/nyttikord/gokord/state" ) +var ErrSyncingUnsavedData = errors.New("trying to sync unsaved data") + type CopaingCached struct { - ID uint `gorm:"primarykey"` - DiscordID string `gorm:"not null"` - GuildID string `gorm:"not null"` + ID uint + DiscordID string + GuildID string XPs uint XPToAdd uint + lastSync time.Time // time.Time of the lastSync } // copaing turns a CopaingCached into a Copaing. -// This operation is heavy. -func (cc *CopaingCached) copaing(ctx context.Context) *Copaing { - c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} - if err := c.Load(ctx); err != nil { +// This operation get the copaing synced with the database. +// It doesn't: +// - save the copaing in the database, use CopaingCached.SaveInDB for that; +// - sync the copaing cached, use CopaingCached.Sync for that. +// TL;DR: don't use this method, unless you know what are you doing. +func (cc *CopaingCached) copaing() *Copaing { + c := &Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} + if err := c.load(); err != nil { panic(err) } - return &c + return c } func (cc *CopaingCached) Sync(ctx context.Context) error { - synced, err := GetState(ctx).CopaingAdd(cc.copaing(ctx), cc.XPToAdd) + if cc.mustSave() { + return ErrSyncingUnsavedData + } + synced := FromCopaing(cc.copaing()) + synced.XPs += cc.XPToAdd + synced.XPToAdd = cc.XPToAdd + err := synced.Save(ctx) if err != nil { return err } @@ -44,7 +59,7 @@ func (cc *CopaingCached) Save(ctx context.Context) error { } func (cc *CopaingCached) SaveInDB(ctx context.Context) error { - c := cc.copaing(ctx) + c := cc.copaing() c.CopaingXPs = append(c.CopaingXPs, CopaingXP{CopaingID: c.ID, XP: cc.XPToAdd, GuildID: c.GuildID}) err := c.Save() if err != nil { @@ -55,12 +70,33 @@ func (cc *CopaingCached) SaveInDB(ctx context.Context) error { } func (cc *CopaingCached) Delete(ctx context.Context) error { - c := cc.copaing(ctx) + c := cc.copaing() err := c.Delete() if err != nil { return err } - return GetState(ctx).CopaingRemove(c) + state := GetState(ctx) + + state.mu.Lock() + defer state.mu.Unlock() + + return state.storage.Delete(KeyCopaingCached(c)) +} + +func (cc *CopaingCached) mustSave() bool { + return cc.XPToAdd > 0 +} + +func saveStateInDB(ctx context.Context) error { + for _, v := range GetState(ctx).storage { + if v.mustSave() { + err := v.SaveInDB(ctx) + if err != nil { + return err + } + } + } + return nil } func FromCopaing(c *Copaing) *CopaingCached { @@ -116,28 +152,17 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { return &mC, nil } -// CopaingAdd does not call Copaing.Load! -func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { - var err error - var cc *CopaingCached - if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { - cc.XPs = calcXP(c) + xpToAdd - } else { - cc = FromCopaing(c) - } - cc.XPToAdd = xpToAdd - - s.mu.Lock() - defer s.mu.Unlock() - - return cc, s.storage.Write(KeyCopaingCached(c), *cc) -} - -func (s *State) CopaingRemove(c *Copaing) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *State) Copaings(guild string) []CopaingCached { + s.mu.RLock() + defer s.mu.RUnlock() - return s.storage.Delete(KeyCopaingCached(c)) + var ccs []CopaingCached + for _, cc := range s.storage { + if cc.GuildID == guild { + ccs = append(ccs, cc) + } + } + return ccs } func calcXP(c *Copaing) uint { diff --git a/user/xp.go b/user/xp.go index 246b097..985b5f8 100644 --- a/user/xp.go +++ b/user/xp.go @@ -97,16 +97,14 @@ func GetBestXP(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAcc logger.Error("scanning rows", "error", err, "copaing", c.ID, "guild", c.GuildID) continue } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { xp, err := c.GetXPForDays(logger, uint(d)) if err != nil { logger.Error("fetching xp", "error", err, "copaing", c.ID, "guild", c.GuildID) return } l = append(l, &cXP{Cxp: xp, copaing: &c}) - }() + }) } wg.Wait() slices.SortFunc(l, func(a, b *cXP) int { -- cgit v1.2.3 From ec5cfa632eeb607351f67bad6686ec872291bd61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 19:57:28 +0100 Subject: perf(command): top now partially uses state --- commands/top.go | 93 +++++++++++++++++++++++++++++--------------------------- exp/functions.go | 2 +- main.go | 6 +++- user/state.go | 7 +++-- user/xp.go | 16 ++++++++-- 5 files changed, 71 insertions(+), 53 deletions(-) diff --git a/commands/top.go b/commands/top.go index bd92a28..fa12a66 100644 --- a/commands/top.go +++ b/commands/top.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "sync" @@ -13,62 +14,64 @@ import ( "github.com/nyttikord/gokord/event" ) -func Top(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { - err := resp.IsDeferred().Send() - if err != nil { - s.Logger().Error("sending deferred", "error", err) - return - } - embeds := make([]*channel.MessageEmbed, 3) - wg := sync.WaitGroup{} - - fn := func(str string, n uint, d int, id int) { - defer wg.Done() - tops, err := user.GetBestXP(s.Logger(), i.GuildID, n, d) +func Top(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { + return func(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { + err := resp.IsDeferred().Send() if err != nil { - s.Logger().Error("fetching best xp", "error", err, "n", n, "d", d, "id", id, "guild", i.GuildID) + s.Logger().Error("sending deferred", "error", err) + return + } + embeds := make([]*channel.MessageEmbed, 3) + wg := sync.WaitGroup{} + + fn := func(str string, n uint, d int, id int) { + defer wg.Done() + tops, err := user.GetBestXP(ctx, s.Logger(), i.GuildID, n, d) + if err != nil { + s.Logger().Error("fetching best xp", "error", err, "n", n, "d", d, "id", id, "guild", i.GuildID) + embeds[id] = &channel.MessageEmbed{ + Title: str, + Description: "Erreur : impossible de récupérer la liste", + Color: 0x831010, + } + return + } embeds[id] = &channel.MessageEmbed{ Title: str, - Description: "Erreur : impossible de récupérer la liste", - Color: 0x831010, + Description: genTopsMessage(tops), + Color: 0x10E6AD, } - return - } - embeds[id] = &channel.MessageEmbed{ - Title: str, - Description: genTopsMessage(tops), - Color: 0x10E6AD, } - } - cfg := config.GetGuildConfig(i.GuildID) - if cfg.DaysXPRemains > 30 { - wg.Add(1) - go fn(fmt.Sprintf("Top %d jours", cfg.DaysXPRemains), 10, -1, 0) - } - wg.Add(2) - go fn("Top 30 jours", 5, 30, 1) - go fn("Top 7 jours", 5, 7, 2) - go func() { - wg.Wait() + cfg := config.GetGuildConfig(i.GuildID) if cfg.DaysXPRemains > 30 { - resp.AddEmbed(embeds[0]). - AddEmbed(embeds[1]). - AddEmbed(embeds[2]) - } else { - resp.AddEmbed(embeds[1]). - AddEmbed(embeds[2]) - } - err = resp.Send() - if err != nil { - s.Logger().Error("sending response top", "error", err) + wg.Add(1) + go fn(fmt.Sprintf("Top %d jours", cfg.DaysXPRemains), 10, -1, 0) } - }() + wg.Add(2) + go fn("Top 30 jours", 5, 30, 1) + go fn("Top 7 jours", 5, 7, 2) + go func() { + wg.Wait() + if cfg.DaysXPRemains > 30 { + resp.AddEmbed(embeds[0]). + AddEmbed(embeds[1]). + AddEmbed(embeds[2]) + } else { + resp.AddEmbed(embeds[1]). + AddEmbed(embeds[2]) + } + err = resp.Send() + if err != nil { + s.Logger().Error("sending response top", "error", err) + } + }() + } } -func genTopsMessage(tops []user.CopaingAccess) string { +func genTopsMessage(tops []user.CopaingCached) string { msg := "" for i, c := range tops { - msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.Copaing().DiscordID, exp.Level(c.GetXP())) + msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.DiscordID, exp.Level(c.XPs)) if i != len(tops)-1 { msg += "\n" } diff --git a/exp/functions.go b/exp/functions.go index 681c135..1b2fe89 100644 --- a/exp/functions.go +++ b/exp/functions.go @@ -11,7 +11,7 @@ import ( "github.com/anhgelus/gokord" ) -const DebugFactor = 30 +const DebugFactor = 5 func MessageXP(length uint, diversity uint) uint { return uint(math.Floor( diff --git a/main.go b/main.go index 0d5bcd3..1012f19 100644 --- a/main.go +++ b/main.go @@ -99,7 +99,7 @@ func main() { SetHandler(commands.ConfigCommand) topCmd := cmd.New("top", "Copaings les plus actifs"). - SetHandler(commands.Top) + SetHandler(commands.Top(ctx)) resetCmd := cmd.New("reset", "Reset l'xp"). SetHandler(commands.Reset). @@ -293,4 +293,8 @@ func main() { if stopPeriodicReducer != nil { stopPeriodicReducer <- true } + + if stopPeriodicSaver != nil { + stopPeriodicSaver <- true + } } diff --git a/user/state.go b/user/state.go index 540d496..b977fb6 100644 --- a/user/state.go +++ b/user/state.go @@ -41,6 +41,7 @@ func (cc *CopaingCached) Sync(ctx context.Context) error { synced := FromCopaing(cc.copaing()) synced.XPs += cc.XPToAdd synced.XPToAdd = cc.XPToAdd + synced.lastSync = time.Now() err := synced.Save(ctx) if err != nil { return err @@ -144,12 +145,12 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { s.mu.RLock() defer s.mu.RUnlock() - c, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) + raw, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) if err != nil { return nil, err } - mC := c.(CopaingCached) - return &mC, nil + c := raw.(CopaingCached) + return &c, nil } func (s *State) Copaings(guild string) []CopaingCached { diff --git a/user/xp.go b/user/xp.go index 985b5f8..9c6c6ab 100644 --- a/user/xp.go +++ b/user/xp.go @@ -78,10 +78,11 @@ func (c *Copaing) GetXPForDays(logger *slog.Logger, n uint) (uint, error) { // GetBestXP returns n Copaing with the best XP within d days (d <= cfg.DaysXPRemain; d < 0 <=> d = cfg.DaysXPRemain) // // This function is slow -func GetBestXP(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAccess, error) { +func GetBestXP(ctx context.Context, logger *slog.Logger, guildId string, n uint, d int) ([]CopaingCached, error) { if d < 0 { cfg := config.GetGuildConfig(guildId) d = int(cfg.DaysXPRemains) + return getBestXPFull(ctx, guildId, n), nil } rows, err := gokord.DB.Model(&Copaing{}).Where("guild_id = ?", guildId).Rows() if err != nil { @@ -112,9 +113,18 @@ func GetBestXP(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAcc return int(b.Cxp) - int(a.Cxp) }) m := min(len(l), int(n)) - cs := make([]CopaingAccess, m) + cs := make([]CopaingCached, m) for i, c := range l[:m] { - cs[i] = c + cs[i] = CopaingCached{DiscordID: c.copaing.DiscordID, XPs: c.Cxp} } return cs, nil } + +func getBestXPFull(ctx context.Context, guildId string, n uint) []CopaingCached { + ccs := GetState(ctx).Copaings(guildId) + slices.SortFunc(ccs, func(a, b CopaingCached) int { + return int(b.XPs) - int(a.XPs) + }) + m := min(len(ccs), int(n)) + return ccs[:m] +} -- cgit v1.2.3 From c661541e45dddd6a082af66fcf7df7ba7dfdc6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 21:50:54 +0100 Subject: perf(command): store data used by top in state --- commands/rank.go | 7 ++--- commands/top.go | 11 ++----- exp/functions.go | 2 +- justfile | 5 +-- user/level.go | 4 +-- user/state.go | 63 +++++++++++++++++++++++++++++++------ user/xp.go | 96 ++++++++++---------------------------------------------- 7 files changed, 79 insertions(+), 109 deletions(-) diff --git a/commands/rank.go b/commands/rank.go index c65c758..3a017f6 100644 --- a/commands/rank.go +++ b/commands/rank.go @@ -38,15 +38,12 @@ func Rank(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, o c = user.GetCopaing(ctx, u.ID, i.GuildID) // current user = member targeted by member who wrote /rank msg = fmt.Sprintf("Le niveau de %s", m.DisplayName()) } - xp := c.XPs + xp := c.XP lvl := exp.Level(xp) nxtLvlXP := exp.LevelXP(lvl + 1) err = resp.SetMessage(fmt.Sprintf( "%s : **%d**\n> XP : %d\n> Prochain niveau dans %d XP", - msg, - lvl, - xp, - nxtLvlXP-xp, + msg, lvl, xp, nxtLvlXP-xp, )).Send() if err != nil { s.Logger().Error("sending rank", "error", err) diff --git a/commands/top.go b/commands/top.go index fa12a66..99c8f16 100644 --- a/commands/top.go +++ b/commands/top.go @@ -16,17 +16,12 @@ import ( func Top(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { return func(s bot.Session, i *event.InteractionCreate, _ cmd.OptionMap, resp *cmd.ResponseBuilder) { - err := resp.IsDeferred().Send() - if err != nil { - s.Logger().Error("sending deferred", "error", err) - return - } embeds := make([]*channel.MessageEmbed, 3) wg := sync.WaitGroup{} fn := func(str string, n uint, d int, id int) { defer wg.Done() - tops, err := user.GetBestXP(ctx, s.Logger(), i.GuildID, n, d) + tops, err := user.GetBestXP(ctx, i.GuildID, n, d) if err != nil { s.Logger().Error("fetching best xp", "error", err, "n", n, "d", d, "id", id, "guild", i.GuildID) embeds[id] = &channel.MessageEmbed{ @@ -60,7 +55,7 @@ func Top(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, _ resp.AddEmbed(embeds[1]). AddEmbed(embeds[2]) } - err = resp.Send() + err := resp.Send() if err != nil { s.Logger().Error("sending response top", "error", err) } @@ -71,7 +66,7 @@ func Top(ctx context.Context) func(s bot.Session, i *event.InteractionCreate, _ func genTopsMessage(tops []user.CopaingCached) string { msg := "" for i, c := range tops { - msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.DiscordID, exp.Level(c.XPs)) + msg += fmt.Sprintf("%d. **<@%s>** - niveau %d", i+1, c.DiscordID, exp.Level(c.XP)) if i != len(tops)-1 { msg += "\n" } diff --git a/exp/functions.go b/exp/functions.go index 1b2fe89..681c135 100644 --- a/exp/functions.go +++ b/exp/functions.go @@ -11,7 +11,7 @@ import ( "github.com/anhgelus/gokord" ) -const DebugFactor = 5 +const DebugFactor = 30 func MessageXP(length uint, diversity uint) uint { return uint(math.Floor( diff --git a/justfile b/justfile index 4e0c917..e66752f 100644 --- a/justfile +++ b/justfile @@ -12,10 +12,7 @@ update: go run . stop: - podman stop postgres adminer || (echo "no container"; exit 0) - podman network rm db - -clean-network: + podman stop postgres adminer || (echo "no container") podman network rm db || echo "no network" build: diff --git a/user/level.go b/user/level.go index 07df16f..92c10ab 100644 --- a/user/level.go +++ b/user/level.go @@ -93,13 +93,13 @@ func syncCopaings(ctx context.Context, s *discordgo.Session, ccs []CopaingCached s.Logger().Debug("sleeping...") time.Sleep(15 * time.Second) // prevents spamming the API } - oldXp := cc.XPs + oldXp := cc.XP err := cc.Sync(ctx) if err != nil { s.Logger().Error("syncing copaing", "error", err, "copaing", cc.ID, "guild", cc.GuildID) continue } - xp := cc.XPs + xp := cc.XP if exp.Level(oldXp) != exp.Level(xp) { cc.onNewLevel(s, exp.Level(xp)) } diff --git a/user/state.go b/user/state.go index b977fb6..effdc80 100644 --- a/user/state.go +++ b/user/state.go @@ -6,18 +6,24 @@ import ( "sync" "time" + "github.com/anhgelus/gokord" "github.com/nyttikord/gokord/state" ) var ErrSyncingUnsavedData = errors.New("trying to sync unsaved data") +type XPCached struct { + XP uint + Time time.Duration +} + type CopaingCached struct { ID uint DiscordID string GuildID string - XPs uint + XP uint + XPs []XPCached XPToAdd uint - lastSync time.Time // time.Time of the lastSync } // copaing turns a CopaingCached into a Copaing. @@ -39,9 +45,8 @@ func (cc *CopaingCached) Sync(ctx context.Context) error { return ErrSyncingUnsavedData } synced := FromCopaing(cc.copaing()) - synced.XPs += cc.XPToAdd + synced.XP += cc.XPToAdd synced.XPToAdd = cc.XPToAdd - synced.lastSync = time.Now() err := synced.Save(ctx) if err != nil { return err @@ -89,7 +94,12 @@ func (cc *CopaingCached) mustSave() bool { } func saveStateInDB(ctx context.Context) error { - for _, v := range GetState(ctx).storage { + state := GetState(ctx) + + state.saveInDB.Lock() + defer state.saveInDB.Unlock() + + for _, v := range state.storage { if v.mustSave() { err := v.SaveInDB(ctx) if err != nil { @@ -105,7 +115,8 @@ func FromCopaing(c *Copaing) *CopaingCached { ID: c.ID, DiscordID: c.DiscordID, GuildID: c.GuildID, - XPs: calcXP(c), + XP: calcXP(c), + XPs: generateXPs(c), XPToAdd: 0, } } @@ -121,14 +132,24 @@ func KeyCopaingCachedRaw(guildID, copaingID string) state.Key { } type State struct { - mu sync.RWMutex - storage state.MapStorage[CopaingCached] + mu sync.RWMutex + saveInDB sync.Mutex + storage state.MapStorage[CopaingCached] } func NewState() *State { - return &State{ + state := &State{ storage: state.MapStorage[CopaingCached]{}, } + var cs []*Copaing + err := gokord.DB.Find(&cs).Error + if err != nil { + panic(err) + } + for _, v := range cs { + FromCopaing(v).Save(SetState(context.Background(), state)) + } + return state } const ContextKeyState = "state" @@ -173,3 +194,27 @@ func calcXP(c *Copaing) uint { } return sum } + +func generateXPs(c *Copaing) []XPCached { + data := map[time.Duration]XPCached{} + sixH := 6 * time.Hour + for _, xp := range c.CopaingXPs { + // we add sixH at the end because we want it to be rounded to ceil + since := time.Since(xp.CreatedAt)/sixH + sixH + if v, ok := data[since]; ok { + v.XP += xp.XP + } else { + data[since] = XPCached{ + Time: since, + XP: xp.XP, + } + } + } + ccs := make([]XPCached, len(data)) + i := 0 + for _, v := range data { + ccs[i] = v + i++ + } + return ccs +} diff --git a/user/xp.go b/user/xp.go index 9c6c6ab..c87c450 100644 --- a/user/xp.go +++ b/user/xp.go @@ -2,13 +2,10 @@ package user import ( "context" - "log/slog" "slices" - "sync" + "time" - "git.anhgelus.world/anhgelus/les-copaings-bot/config" "git.anhgelus.world/anhgelus/les-copaings-bot/exp" - "github.com/anhgelus/gokord" "github.com/nyttikord/gokord/bot" "github.com/nyttikord/gokord/user" ) @@ -27,10 +24,10 @@ func (c *cXP) GetXP() uint { } func (cc *CopaingCached) AddXP(ctx context.Context, s bot.Session, m *user.Member, xp uint, fn func(uint, uint)) { - old := cc.XPs + old := cc.XP pastLevel := exp.Level(old) s.Logger().Debug("adding xp", "user", m.DisplayName(), "old", old, "to add", xp) - cc.XPs += xp + cc.XP += xp cc.XPToAdd += xp if err := cc.Save(ctx); err != nil { s.Logger().Error("saving user in state", "error", err, "user", m.DisplayName(), "xp", xp, "guild", cc.GuildID) @@ -43,88 +40,27 @@ func (cc *CopaingCached) AddXP(ctx context.Context, s bot.Session, m *user.Membe } } -func (c *Copaing) GetXP(logger *slog.Logger) (uint, error) { - cfg := config.GetGuildConfig(c.GuildID) - return c.GetXPForDays(logger, cfg.DaysXPRemains) -} - -func (c *Copaing) GetXPForDays(logger *slog.Logger, n uint) (uint, error) { +func (cc *CopaingCached) GetXPForDays(n uint) uint { xp := uint(0) - rows, err := gokord.DB. - Model(&CopaingXP{}). - Where( - "created_at >= ? and guild_id = ? and copaing_id = ?", - exp.TimeStampNDaysBefore(n), - c.GuildID, - c.ID, - ). - Rows() - if err != nil { - return 0, err - } - defer rows.Close() - for rows.Next() { - var cxp CopaingXP - err = gokord.DB.ScanRows(rows, &cxp) - if err != nil { - logger.Error("scanning rows", "error", err, "copaing", c.ID, "guild", c.GuildID) - continue + for _, v := range cc.XPs { + if v.Time <= time.Duration(n*24)*time.Hour { + xp += v.XP } - xp += cxp.XP } - return xp, nil + return xp + cc.XPToAdd } // GetBestXP returns n Copaing with the best XP within d days (d <= cfg.DaysXPRemain; d < 0 <=> d = cfg.DaysXPRemain) -// -// This function is slow -func GetBestXP(ctx context.Context, logger *slog.Logger, guildId string, n uint, d int) ([]CopaingCached, error) { - if d < 0 { - cfg := config.GetGuildConfig(guildId) - d = int(cfg.DaysXPRemains) - return getBestXPFull(ctx, guildId, n), nil - } - rows, err := gokord.DB.Model(&Copaing{}).Where("guild_id = ?", guildId).Rows() - if err != nil { - return nil, err - } - defer rows.Close() - var l []*cXP - var wg sync.WaitGroup - for rows.Next() { - var c Copaing - err = gokord.DB.ScanRows(rows, &c) - if err != nil { - logger.Error("scanning rows", "error", err, "copaing", c.ID, "guild", c.GuildID) - continue +func GetBestXP(ctx context.Context, guildId string, n uint, d int) ([]CopaingCached, error) { + ccs := GetState(ctx).Copaings(guildId) + if d > 0 { + for _, v := range ccs { + v.XP = v.GetXPForDays(n) } - wg.Go(func() { - xp, err := c.GetXPForDays(logger, uint(d)) - if err != nil { - logger.Error("fetching xp", "error", err, "copaing", c.ID, "guild", c.GuildID) - return - } - l = append(l, &cXP{Cxp: xp, copaing: &c}) - }) } - wg.Wait() - slices.SortFunc(l, func(a, b *cXP) int { - // desc order - return int(b.Cxp) - int(a.Cxp) - }) - m := min(len(l), int(n)) - cs := make([]CopaingCached, m) - for i, c := range l[:m] { - cs[i] = CopaingCached{DiscordID: c.copaing.DiscordID, XPs: c.Cxp} - } - return cs, nil -} - -func getBestXPFull(ctx context.Context, guildId string, n uint) []CopaingCached { - ccs := GetState(ctx).Copaings(guildId) slices.SortFunc(ccs, func(a, b CopaingCached) int { - return int(b.XPs) - int(a.XPs) + // desc order + return int(b.XP) - int(a.XP) }) - m := min(len(ccs), int(n)) - return ccs[:m] + return ccs[:min(len(ccs), int(n))], nil } -- cgit v1.2.3