From c00d3d0186f2129f71645c7357feb0fb56669e0f Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Tue, 10 Feb 2026 14:03:41 -0500 Subject: [PATCH] cmd/horsebot: move to here --- cmd/horsebot/.gitignore | 1 + cmd/horsebot/README.md | 10 + cmd/horsebot/autocomplete/autocomplete.go | 56 ++++++ .../autocomplete/autocomplete_test.go | 70 +++++++ cmd/horsebot/log.go | 55 ++++++ cmd/horsebot/main.go | 183 ++++++++++++++++++ cmd/horsebot/skill.go | 148 ++++++++++++++ go.mod | 14 +- go.sum | 36 +++- 9 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 cmd/horsebot/.gitignore create mode 100644 cmd/horsebot/README.md create mode 100644 cmd/horsebot/autocomplete/autocomplete.go create mode 100644 cmd/horsebot/autocomplete/autocomplete_test.go create mode 100644 cmd/horsebot/log.go create mode 100644 cmd/horsebot/main.go create mode 100644 cmd/horsebot/skill.go diff --git a/cmd/horsebot/.gitignore b/cmd/horsebot/.gitignore new file mode 100644 index 0000000..8d865be --- /dev/null +++ b/cmd/horsebot/.gitignore @@ -0,0 +1 @@ +token diff --git a/cmd/horsebot/README.md b/cmd/horsebot/README.md new file mode 100644 index 0000000..3068a2d --- /dev/null +++ b/cmd/horsebot/README.md @@ -0,0 +1,10 @@ +# horsebot + +Discord bot serving horse game data. + +Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations. + +## Running + +The bot always uses the Gateway API. +If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided. diff --git a/cmd/horsebot/autocomplete/autocomplete.go b/cmd/horsebot/autocomplete/autocomplete.go new file mode 100644 index 0000000..0332e29 --- /dev/null +++ b/cmd/horsebot/autocomplete/autocomplete.go @@ -0,0 +1,56 @@ +package autocomplete + +import ( + "bytes" + "cmp" + "slices" + "sync" + + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" +) + +// Set is an autocomplete set. +type Set[V any] struct { + keys []util.Chars + vals []V +} + +// Add associates a value with a key in the autocomplete set. +// The behavior is undefined if the key already has a value. +func (s *Set[V]) Add(key string, val V) { + k := util.ToChars([]byte(key)) + i, _ := slices.BinarySearchFunc(s.keys, k, func(a, b util.Chars) int { + return bytes.Compare(a.Bytes(), b.Bytes()) + }) + s.keys = slices.Insert(s.keys, i, k) + s.vals = slices.Insert(s.vals, i, val) +} + +// Find appends to r all values in the set with keys that key matches. +func (s *Set[V]) Find(r []V, key string) []V { + initFzf() + var ( + p = []rune(key) + + got []V + t []algo.Result + slab util.Slab + ) + for i := range s.keys { + res, _ := algo.FuzzyMatchV2(false, true, true, &s.keys[i], p, false, &slab) + if res.Score <= 0 { + continue + } + j, _ := slices.BinarySearchFunc(t, res, func(a, b algo.Result) int { return -cmp.Compare(a.Score, b.Score) }) + // Insert after all other matches with the same score for stability. + for j < len(t) && t[j].Score == res.Score { + j++ + } + t = slices.Insert(t, j, res) + got = slices.Insert(got, j, s.vals[i]) + } + return append(r, got...) +} + +var initFzf = sync.OnceFunc(func() { algo.Init("default") }) diff --git a/cmd/horsebot/autocomplete/autocomplete_test.go b/cmd/horsebot/autocomplete/autocomplete_test.go new file mode 100644 index 0000000..a1ef6b6 --- /dev/null +++ b/cmd/horsebot/autocomplete/autocomplete_test.go @@ -0,0 +1,70 @@ +package autocomplete_test + +import ( + "slices" + "testing" + + "git.sunturtle.xyz/zephyr/horse/cmd/horsebot/autocomplete" +) + +func these(s ...string) []string { return s } + +func TestAutocomplete(t *testing.T) { + cases := []struct { + name string + add []string + search string + want []string + }{ + { + name: "empty", + add: nil, + search: "", + want: nil, + }, + { + name: "exact", + add: these("bocchi"), + search: "bocchi", + want: these("bocchi"), + }, + { + name: "extra", + add: these("bocchi", "ryo", "nijika", "kita"), + search: "bocchi", + want: these("bocchi"), + }, + { + name: "short", + add: these("bocchi", "ryo", "nijika", "kita"), + search: "o", + want: these("bocchi", "ryo"), + }, + { + name: "unrelated", + add: these("bocchi", "ryo", "nijika", "kita"), + search: "x", + want: nil, + }, + { + name: "map", + add: these("Corazón ☆ Ardiente"), + search: "corazo", + want: these("Corazón ☆ Ardiente"), + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var set autocomplete.Set[string] + for _, s := range c.add { + set.Add(s, s) + } + got := set.Find(nil, c.search) + slices.Sort(c.want) + slices.Sort(got) + if !slices.Equal(c.want, got) { + t.Errorf("wrong results: want %q, got %q", c.want, got) + } + }) + } +} diff --git a/cmd/horsebot/log.go b/cmd/horsebot/log.go new file mode 100644 index 0000000..46dd89c --- /dev/null +++ b/cmd/horsebot/log.go @@ -0,0 +1,55 @@ +package main + +import ( + "log/slog" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" +) + +func logMiddleware(next handler.Handler) handler.Handler { + return func(e *handler.InteractionEvent) error { + var msg string + attrs := make([]slog.Attr, 0, 8) + attrs = append(attrs, + slog.Uint64("interaction", uint64(e.Interaction.ID())), + slog.Uint64("user", uint64(e.Interaction.User().ID)), + ) + if guild := e.Interaction.GuildID(); guild != nil { + attrs = append(attrs, slog.String("guild", guild.String())) + } + switch i := e.Interaction.(type) { + case discord.ApplicationCommandInteraction: + msg = "command" + attrs = append(attrs, + slog.String("name", i.Data.CommandName()), + slog.Int("type", int(i.Data.Type())), + ) + switch data := i.Data.(type) { + case discord.SlashCommandInteractionData: + attrs = append(attrs, slog.String("path", data.CommandPath())) + } + + case discord.AutocompleteInteraction: + msg = "autocomplete" + attrs = append(attrs, + slog.String("name", i.Data.CommandName), + slog.String("path", i.Data.CommandPath()), + slog.String("focus", i.Data.Focused().Name), + ) + + case discord.ComponentInteraction: + msg = "component" + attrs = append(attrs, + slog.Int("type", int(i.Data.Type())), + slog.String("custom", i.Data.CustomID()), + ) + + default: + slog.WarnContext(e.Ctx, "unknown interaction", slog.Any("event", e)) + return nil + } + slog.LogAttrs(e.Ctx, slog.LevelInfo, msg, attrs...) + return next(e) + } +} diff --git a/cmd/horsebot/main.go b/cmd/horsebot/main.go new file mode 100644 index 0000000..00ee6a9 --- /dev/null +++ b/cmd/horsebot/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "time" + + "github.com/disgoorg/disgo" + "github.com/disgoorg/disgo/bot" + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgo/handler/middleware" + "github.com/disgoorg/disgo/httpserver" + "github.com/disgoorg/disgo/rest" + + "git.sunturtle.xyz/zephyr/horse/horse" + "git.sunturtle.xyz/zephyr/horse/horse/global" +) + +func main() { + var ( + tokenFile string + // http api options + addr string + route string + pubkey string + // logging options + level slog.Level + textfmt string + ) + flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") + flag.StringVar(&addr, "http", "", "`address` to bind HTTP API server") + flag.StringVar(&route, "route", "/interactions/callback", "`path` to serve HTTP API calls") + flag.StringVar(&pubkey, "key", "", "Discord public key") + flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`") + flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json") + flag.Parse() + + var lh slog.Handler + switch textfmt { + case "text": + lh = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}) + case "json": + lh = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}) + default: + fmt.Fprintf(os.Stderr, "invalid log format %q, must be text or json", textfmt) + os.Exit(1) + } + slog.SetDefault(slog.New(lh)) + + token, err := os.ReadFile(tokenFile) + if err != nil { + slog.Error("reading token", slog.Any("err", err)) + os.Exit(1) + } + token = bytes.TrimSuffix(token, []byte{'\n'}) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + + r := handler.New() + r.DefaultContext(func() context.Context { return ctx }) + r.Use(middleware.Go) + r.Use(logMiddleware) + r.Route("/skill", func(r handler.Router) { + r.SlashCommand("/", skillHandler) + r.Autocomplete("/", skillAutocomplete) + r.ButtonComponent("/{id}", skillButton) + }) + + opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)} + if addr != "" { + if pubkey == "" { + slog.Error("Discord public key must be provided when using HTTP API") + os.Exit(1) + } + opts = append(opts, bot.WithHTTPServerConfigOpts(pubkey, + httpserver.WithAddress(addr), + httpserver.WithURL(route), + )) + } + + slog.Info("connect", slog.String("disgo", disgo.Version)) + client, err := disgo.New(string(token), opts...) + if err != nil { + slog.Error("building bot", slog.Any("err", err)) + os.Exit(1) + } + + if err := handler.SyncCommands(client, commands, nil, rest.WithCtx(ctx)); err != nil { + slog.Error("syncing commands", slog.Any("err", err)) + os.Exit(1) + } + + if addr != "" { + slog.Info("start HTTP server", slog.String("address", addr), slog.String("route", route)) + if err := client.OpenHTTPServer(); err != nil { + slog.Error("starting HTTP server", slog.Any("err", err)) + stop() + } + } + slog.Info("start gateway") + if err := client.OpenGateway(ctx); err != nil { + slog.Error("starting gateway", slog.Any("err", err)) + stop() + } + slog.Info("ready") + <-ctx.Done() + stop() + + ctx, stop = context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + client.Close(ctx) +} + +var commands = []discord.ApplicationCommandCreate{ + discord.SlashCommandCreate{ + Name: "skill", + Description: "Umamusume skill data", + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "query", + Description: "Skill name or ID", + Required: true, + Autocomplete: true, + }, + }, + }, +} + +func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + q := data.String("query") + id, err := strconv.ParseInt(q, 10, 32) + if err == nil { + // note inverted condition; this is when we have an id + id = int64(global.AllSkills[horse.SkillID(id)].ID) + } + if id == 0 { + // Either we weren't given a number or the number doesn't match any skill ID. + v := global.SkillNameToID[q] + if v == 0 { + // No such skill. + m := discord.MessageCreate{ + Content: "No such skill.", + Flags: discord.MessageFlagEphemeral, + } + return e.CreateMessage(m) + } + id = int64(v) + } + // TODO(zeph): search conditions and effects, give a list + m := discord.MessageCreate{ + Components: []discord.LayoutComponent{RenderSkill(horse.SkillID(id), global.AllSkills, global.SkillGroups)}, + Flags: discord.MessageFlagIsComponentsV2, + } + return e.CreateMessage(m) +} + +func skillAutocomplete(e *handler.AutocompleteEvent) error { + q := e.Data.String("query") + opts := skillGlobalAuto().Find(nil, q) + return e.AutocompleteResult(opts[:min(len(opts), 25)]) +} + +func skillButton(data discord.ButtonInteractionData, e *handler.ComponentEvent) error { + id, err := strconv.ParseInt(e.Vars["id"], 10, 32) + if err != nil { + m := discord.MessageCreate{ + Content: "That button produced an invalid skill ID. That's not supposed to happen.", + Flags: discord.MessageFlagEphemeral, + } + return e.CreateMessage(m) + } + m := discord.MessageUpdate{ + Components: &[]discord.LayoutComponent{RenderSkill(horse.SkillID(id), global.AllSkills, global.SkillGroups)}, + } + return e.UpdateMessage(m) +} diff --git a/cmd/horsebot/skill.go b/cmd/horsebot/skill.go new file mode 100644 index 0000000..3e50425 --- /dev/null +++ b/cmd/horsebot/skill.go @@ -0,0 +1,148 @@ +package main + +import ( + "fmt" + "strings" + "sync" + + "github.com/disgoorg/disgo/discord" + + "git.sunturtle.xyz/zephyr/horse/cmd/horsebot/autocomplete" + "git.sunturtle.xyz/zephyr/horse/horse" + "git.sunturtle.xyz/zephyr/horse/horse/global" +) + +func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map[int32][4]horse.SkillID) discord.ContainerComponent { + s, ok := all[id] + if !ok { + return discord.NewContainer(discord.NewTextDisplayf("invalid skill ID %v made it to RenderSkill", id)) + } + + thumburl := fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID) + top := "## " + s.Name + if s.UniqueOwner != "" { + top += "\n-# " + s.UniqueOwner + } + r := discord.NewContainer( + discord.NewSection( + discord.NewTextDisplay(top), + discord.NewTextDisplay(s.Description), + ).WithAccessory(discord.NewThumbnail(thumburl)), + ) + var skilltype string + switch { + case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5: + // unique of various star levels + r.AccentColor = 0xaca4d4 + skilltype = "Unique Skill" + case s.UniqueOwner != "": + r.AccentColor = 0xcccccc + skilltype = "Inherited Unique" + case s.Rarity == 2: + // rare (gold) + r.AccentColor = 0xd7c25b + skilltype = "Rare Skill" + case s.GroupRate == -1: + // negative (purple) skill + r.AccentColor = 0x9151d4 + skilltype = "Negative Skill" + case !s.WitCheck: + // should be passive (green) + r.AccentColor = 0x66ae1c + skilltype = "Passive Skill" + case isDebuff(s): + // debuff (red) + r.AccentColor = 0xe34747 + skilltype = "Debuff Skill" + case s.Rarity == 1: + // common (white) + r.AccentColor = 0xcccccc + skilltype = "Common Skill" + } + r.Components = append(r.Components, discord.NewSmallSeparator()) + text := make([]string, 0, 3) + abils := make([]string, 0, 3) + for _, act := range s.Activations { + text, abils = text[:0], abils[:0] + if act.Precondition != "" { + text = append(text, "Precondition: "+formatCondition(act.Precondition)) + } + text = append(text, "Condition: "+formatCondition(act.Condition)) + var t string + switch { + case act.Duration < 0: + // passive; do nothing + case act.Duration == 0: + t = "Instantaneous " + case act.Duration >= 500e4: + t = "Permanent " + default: + t = "For " + act.Duration.String() + "s, " + } + for _, a := range act.Abilities { + abils = append(abils, a.String()) + } + t += strings.Join(abils, ", ") + if act.Cooldown > 0 && act.Cooldown < 500e4 { + t += " on " + act.Cooldown.String() + "s cooldown" + } + text = append(text, t) + r.Components = append(r.Components, discord.NewTextDisplay(strings.Join(text, "\n"))) + } + + l := discord.NewTextDisplayf("%s ・ SP cost %d ・ Grade value %d ・ [Conditions on GameTora](https://gametora.com/umamusume/skill-condition-viewer?skill=%d)", skilltype, s.SPCost, s.GradeValue, s.ID) + r.Components = append(r.Components, discord.NewSmallSeparator(), l) + rel := make([]horse.Skill, 0, 4) + for _, id := range groups[s.Group] { + if id != 0 { + rel = append(rel, all[id]) + } + } + if len(rel) > 1 { + buttons := make([]discord.InteractiveComponent, 0, 4) + for _, rs := range rel { + b := discord.NewSecondaryButton(rs.Name, fmt.Sprintf("/skill/%d", rs.ID)) + if rs.ID == id { + b = b.AsDisabled() + } + buttons = append(buttons, b) + } + r.Components = append(r.Components, discord.NewActionRow(buttons...)) + } + return r +} + +func formatCondition(s string) string { + s = strings.ReplaceAll(s, "&", " & ") + if strings.ContainsRune(s, '@') { + return "```\n" + strings.ReplaceAll(s, "@", "\n@\n") + "```" + } + return "`" + s + "`" +} + +func isDebuff(s horse.Skill) bool { + for _, act := range s.Activations { + for _, a := range act.Abilities { + if a.Value < 0 { + return true + } + } + } + return false +} + +var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set[discord.AutocompleteChoice] { + var set autocomplete.Set[discord.AutocompleteChoice] + for _, id := range global.OrderedSkills { + s := global.AllSkills[id] + set.Add(s.Name, discord.AutocompleteChoiceString{Name: s.Name, Value: s.Name}) + if s.UniqueOwner != "" { + if s.Rarity >= 3 { + set.Add(s.UniqueOwner, discord.AutocompleteChoiceString{Name: "Unique: " + s.UniqueOwner, Value: s.Name}) + } else { + set.Add(s.UniqueOwner, discord.AutocompleteChoiceString{Name: "Inherited unique: " + s.UniqueOwner, Value: s.Name}) + } + } + } + return &set +}) diff --git a/go.mod b/go.mod index 98fd721..a2a366f 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,30 @@ module git.sunturtle.xyz/zephyr/horse -go 1.24.1 +go 1.25.5 require ( + github.com/disgoorg/disgo v0.19.0-rc.15 + github.com/junegunn/fzf v0.67.0 golang.org/x/sync v0.14.0 zombiezen.com/go/sqlite v1.4.2 ) require ( + github.com/disgoorg/json/v2 v2.0.0 // indirect + github.com/disgoorg/omit v1.0.0 // indirect + github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.39.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 56f4fe4..72c2405 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,41 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disgoorg/disgo v0.19.0-rc.15 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU= +github.com/disgoorg/disgo v0.19.0-rc.15/go.mod h1:14mgXzenkJqifkDmsEgU0zI1di6jNXodwX6L8geW33A= +github.com/disgoorg/json/v2 v2.0.0 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI= +github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI= +github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78= +github.com/disgoorg/omit v1.0.0/go.mod h1:RTmSARkf6PWT/UckwI0bV8XgWkWQoPppaT01rYKLcFQ= +github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro= +github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/junegunn/fzf v0.67.0 h1:naiOdIkV5/ZCfHgKQIV/f5YDWowl95G6yyOQqW8FeSo= +github.com/junegunn/fzf v0.67.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= +github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= @@ -17,12 +43,14 @@ golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=