aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnhgelus Morhtuuzh <william@herges.fr>2026-01-17 20:52:13 +0000
committerAnhgelus Morhtuuzh <william@herges.fr>2026-01-17 20:52:13 +0000
commita3543d79561a3754540b921c54c3c177016c2397 (patch)
tree318b29652c4f59a7f6a16ff7a566b1a9935d069d
parent05ec1c26fe884097efe8fe1490916c518028e597 (diff)
parentc661541e45dddd6a082af66fcf7df7ba7dfdc6a6 (diff)
Merge pull request '[Perf] Member state' (#5) from perf/member-state into main
Reviewed-on: https://git.anhgelus.world/anhgelus/les-copaings-bot/pulls/5
-rw-r--r--commands/rank.go72
-rw-r--r--commands/reset.go49
-rw-r--r--commands/stats.go97
-rw-r--r--commands/top.go88
-rw-r--r--events.go32
-rw-r--r--go.mod4
-rw-r--r--justfile10
-rw-r--r--main.go47
-rw-r--r--user/level.go95
-rw-r--r--user/member.go33
-rw-r--r--user/state.go220
-rw-r--r--user/xp.go114
12 files changed, 515 insertions, 346 deletions
diff --git a/commands/rank.go b/commands/rank.go
index 4080864..3a017f6 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,42 @@ 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.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,
+ )).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..ae31781 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"
@@ -10,37 +12,40 @@ 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)
}
}
-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).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/commands/top.go b/commands/top.go
index bb08144..99c8f16 100644
--- a/commands/top.go
+++ b/commands/top.go
@@ -1,6 +1,7 @@
package commands
import (
+ "context"
"fmt"
"sync"
@@ -13,62 +14,59 @@ 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{}
+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) {
+ 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)
- if err != nil {
- s.Logger().Error("fetching best xp", "error", err, "n", n, "d", d, "id", id, "guild", i.GuildID)
+ fn := func(str string, n uint, d int, id int) {
+ defer wg.Done()
+ 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{
+ 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])
+ wg.Add(1)
+ go fn(fmt.Sprintf("Top %d jours", cfg.DaysXPRemains), 10, -1, 0)
}
- err = resp.Send()
- if err != nil {
- s.Logger().Error("sending response top", "error", err)
- }
- }()
+ 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.ToCopaing().DiscordID, exp.Level(c.GetXP()))
+ 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/events.go b/events.go
index 5191120..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"
)
@@ -24,7 +23,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 +31,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 +49,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 +63,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 +76,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 +98,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,20 +112,13 @@ 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)
- 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(); 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/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/justfile b/justfile
index ab58ac1..e66752f 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,8 @@ update:
go run .
stop:
- podman stop postgres adminer
- podman network rm db
-
-clean-network:
- podman network rm db
+ podman stop postgres adminer || (echo "no container")
+ podman network rm db || echo "no network"
build:
GOAMD64=v3 go build -ldflags "-s" .
diff --git a/main.go b/main.go
index d2e6c01..1012f19 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
}
@@ -86,20 +84,22 @@ 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).
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).
@@ -111,7 +111,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 +128,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).
@@ -179,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<- interface{}) {
- dg.Logger().Debug("periodic reducer")
- user.PeriodicReducer(dg)
+ stopPeriodicReducer = gokord.NewTimer(d, func(stop chan<- any) {
+ user.PeriodicReducer(ctx, dg)
+ })
+ stopPeriodicSaver = gokord.NewTimer(d2, func(c chan<- any) {
+ user.PeriodicSaver(ctx, dg)
})
},
Innovations: innovations,
@@ -221,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
@@ -279,9 +288,13 @@ func main() {
b.AddHandler(OnVoiceUpdate)
b.AddHandler(OnLeave)
- b.Start(context.Background())
+ b.Start(ctx)
if stopPeriodicReducer != nil {
stopPeriodicReducer <- true
}
+
+ if stopPeriodicSaver != nil {
+ stopPeriodicSaver <- true
+ }
}
diff --git a/user/level.go b/user/level.go
index e7b96af..92c10ab 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,65 +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()
- xp, err := c.ToCopaing().GetXP(s.Logger())
+ oldXp := cc.XP
+ err := cc.Sync(ctx)
if err != nil {
- s.Logger().Error("getting xp of copaing", "error", err, "copaing", c.ID, "guild", c.GuildID)
+ s.Logger().Error("syncing copaing", "error", err, "copaing", cc.ID, "guild", cc.GuildID)
continue
}
+ xp := cc.XP
if exp.Level(oldXp) != exp.Level(xp) {
- c.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 9068a6f..4969f8a 100644
--- a/user/member.go
+++ b/user/member.go
@@ -1,6 +1,7 @@
package user
import (
+ "context"
"time"
"github.com/anhgelus/gokord"
@@ -22,24 +23,33 @@ type CopaingXP struct {
}
type CopaingAccess interface {
- ToCopaing() *Copaing
+ Copaing() *Copaing
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(); err != nil {
+ panic(err)
+ }
+ cc = FromCopaing(&c)
}
- return &c
+ return cc
}
-func (c *Copaing) Load() error {
- return gokord.DB.
+func (c *Copaing) load() error {
+ err := gokord.DB.
Where("discord_id = ? and guild_id = ?", c.DiscordID, c.GuildID).
Preload("CopaingXPs").
FirstOrCreate(c).
Error
+ if err != nil {
+ return err
+ }
+ return err
}
func (c *Copaing) Save() error {
@@ -47,5 +57,12 @@ func (c *Copaing) Save() error {
}
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
+ }
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
new file mode 100644
index 0000000..effdc80
--- /dev/null
+++ b/user/state.go
@@ -0,0 +1,220 @@
+package user
+
+import (
+ "context"
+ "errors"
+ "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
+ XP uint
+ XPs []XPCached
+ XPToAdd uint
+}
+
+// copaing turns a CopaingCached into a Copaing.
+// 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
+}
+
+func (cc *CopaingCached) Sync(ctx context.Context) error {
+ if cc.mustSave() {
+ return ErrSyncingUnsavedData
+ }
+ synced := FromCopaing(cc.copaing())
+ synced.XP += cc.XPToAdd
+ synced.XPToAdd = cc.XPToAdd
+ err := synced.Save(ctx)
+ if err != nil {
+ return err
+ }
+ *cc = *synced
+ return nil
+}
+
+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 (cc *CopaingCached) SaveInDB(ctx context.Context) error {
+ c := cc.copaing()
+ 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()
+ err := c.Delete()
+ if err != nil {
+ return err
+ }
+ 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 {
+ 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 {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func FromCopaing(c *Copaing) *CopaingCached {
+ return &CopaingCached{
+ ID: c.ID,
+ DiscordID: c.DiscordID,
+ GuildID: c.GuildID,
+ XP: calcXP(c),
+ XPs: generateXPs(c),
+ XPToAdd: 0,
+ }
+}
+
+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
+ saveInDB sync.Mutex
+ storage state.MapStorage[CopaingCached]
+}
+
+func NewState() *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"
+
+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()
+
+ raw, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID))
+ if err != nil {
+ return nil, err
+ }
+ c := raw.(CopaingCached)
+ return &c, nil
+}
+
+func (s *State) Copaings(guild string) []CopaingCached {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var ccs []CopaingCached
+ for _, cc := range s.storage {
+ if cc.GuildID == guild {
+ ccs = append(ccs, cc)
+ }
+ }
+ return ccs
+}
+
+func calcXP(c *Copaing) uint {
+ var sum uint
+ for _, entry := range c.CopaingXPs {
+ sum += entry.XP
+ }
+ 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 dbca9de..c87c450 100644
--- a/user/xp.go
+++ b/user/xp.go
@@ -1,41 +1,36 @@
package user
import (
- "log/slog"
+ "context"
"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"
)
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 {
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.XP
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.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)
return
}
newLevel := exp.Level(old + xp)
@@ -45,86 +40,27 @@ func (c *Copaing) AddXP(s bot.Session, m *user.Member, xp uint, fn func(uint, ui
}
}
-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(logger *slog.Logger, guildId string, n uint, d int) ([]CopaingAccess, error) {
- if d < 0 {
- cfg := config.GetGuildConfig(guildId)
- d = int(cfg.DaysXPRemains)
- }
- rows, err := gokord.DB.Model(&Copaing{}).Where("guild_id = ?", guildId).Rows()
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var l []*cXP
- 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.Add(1)
- go func() {
- defer wg.Done()
- 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 {
+ slices.SortFunc(ccs, func(a, b CopaingCached) int {
// desc order
- if a.Cxp < b.Cxp {
- return 1
- }
- if a.Cxp > b.Cxp {
- return -1
- }
- return 0
+ return int(b.XP) - int(a.XP)
})
- m := min(len(l), int(n))
- cs := make([]CopaingAccess, m)
- for i, c := range l[:m] {
- cs[i] = c
- }
- return cs, nil
+ return ccs[:min(len(ccs), int(n))], nil
}