diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d865be --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +token diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..86d727d --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.sunturtle.xyz/zephyr/horsebot + +go 1.24.1 + +require ( + git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4 + github.com/disgoorg/disgo v0.19.0-rc.15 +) + +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/gorilla/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a1d53fe --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4 h1:YJEGZG/EnxE5Tr6EZMGxikXDF6QKKHrH9Bp1dLoWXkk= +git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk= +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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..117f7f7 --- /dev/null +++ b/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "context" + "flag" + "log/slog" + "os" + "os/signal" + "strconv" + "strings" + "time" + + "git.sunturtle.xyz/zephyr/horse/horse" + "git.sunturtle.xyz/zephyr/horse/horse/global" + "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/rest" +) + +func main() { + var ( + appID string + pubkey string + tokenFile string + ) + flag.StringVar(&appID, "id", "", "Discord application ID") + flag.StringVar(&pubkey, "key", "", "Discord public key") + flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") + flag.Parse() + + 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(middleware.Logger) + r.Route("/skill", func(r handler.Router) { + r.SlashCommand("/", skillHandler) + r.Autocomplete("/", skillAutocomplete) + // TODO(zeph): button handler + }) + + slog.Info("connect", slog.String("disgo", disgo.Version)) + client, err := disgo.New(string(token), bot.WithDefaultGateway(), bot.WithEventListeners(r)) + 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 err := client.OpenGateway(ctx); err != nil { + slog.Error("opening gateway", slog.Any("err", err)) + stop() + } + slog.Info("running") + <-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) + var s horse.Skill + if err == nil { + // note inverted condition; this is when we have an id + s = global.AllSkills[horse.SkillID(id)] + } + if s.ID == 0 { + // Either we weren't given a number or the number doesn't match any skill ID. + id, ok := global.SkillNameToID[q] + if !ok { + // No such skill. + m := discord.MessageCreate{ + Content: "No such skill.", + Flags: discord.MessageFlagEphemeral, + } + return e.CreateMessage(m) + } + s = global.AllSkills[id] + } + // TODO(zeph): search conditions and effects, give a list + m := discord.MessageCreate{ + Components: []discord.LayoutComponent{RenderSkill(s)}, + Flags: discord.MessageFlagIsComponentsV2, + } + return e.CreateMessage(m) +} + +func skillAutocomplete(e *handler.AutocompleteEvent) error { + q := strings.ToLower(e.Data.String("query")) + r := make([]discord.AutocompleteChoice, 0, 25) + for k, _ := range global.SkillNameToID { + if strings.HasPrefix(strings.ToLower(k), q) { + r = append(r, discord.AutocompleteChoiceString{Name: k, Value: k}) + if len(r) == cap(r) { + break + } + } + } + return e.AutocompleteResult(r) +} diff --git a/skill.go b/skill.go new file mode 100644 index 0000000..251ea10 --- /dev/null +++ b/skill.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "strings" + + "git.sunturtle.xyz/zephyr/horse/horse" + "github.com/disgoorg/disgo/discord" +) + +func RenderSkill(s horse.Skill) discord.ContainerComponent { + skilltype := discord.TextDisplayComponent{ + ID: 4, + Content: "Skill Issue", + } + r := discord.ContainerComponent{ + ID: 1, // specify ids so we guarantee we don't get collisions with related skill buttons + Components: []discord.ContainerSubComponent{ + discord.SectionComponent{ + ID: 2, + Components: []discord.SectionSubComponent{ + discord.TextDisplayComponent{ + ID: 3, + Content: "## " + s.Name + "\n" + s.Description, + }, + &skilltype, // doing something evil :) + }, + Accessory: discord.NewThumbnail(fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID)), + }, + }, + } + switch { + case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5: + // unique of various star levels + r.AccentColor = 0xaca4d4 + skilltype.Content = "Unique Skill" + case s.Rarity == 2: + // rare (gold) + r.AccentColor = 0xd7c25b + skilltype.Content = "Rare Skill" + case s.GroupRate == -1: + // negative (purple) skill + r.AccentColor = 0x9151d4 + skilltype.Content = "Negative Skill" + case !s.WitCheck: + // should be passive (green) + r.AccentColor = 0x66ae1c + skilltype.Content = "Passive Skill" + // TODO(zeph): debuff (red) + case s.Rarity == 1: + // common (white) + r.AccentColor = 0xcccccc + skilltype.Content = "Common Skill" + } + r.Components = append(r.Components, discord.SeparatorComponent{ID: 5, Spacing: discord.SeparatorSpacingSizeSmall}) + text := make([]string, 0, 3) + abils := make([]string, 0, 3) + for i, 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 = act.Duration.String() + "s " + } + for _, a := range act.Abilities { + abils = append(abils, a.String()) + } + t += strings.Join(abils, ", ") + if act.Cooldown < 500e4 { + t += " on " + act.Cooldown.String() + "s cooldown" + } + text = append(text, t) + r.Components = append(r.Components, discord.TextDisplayComponent{ + ID: 10 + i, + Content: strings.Join(text, "\n"), + }) + } + r.Components = append(r.Components, discord.SeparatorComponent{ID: 50, Spacing: discord.SeparatorSpacingSizeSmall}) + r.Components = append(r.Components, discord.TextDisplayComponent{ + ID: 51, + Content: fmt.Sprintf("SP cost %d. Grade value %d.", s.SPCost, s.GradeValue), + }) + // TODO(zeph): related skills, use a row of buttons with skill numbers for the ids and edit the message when clicked? + return r +} + +func formatCondition(s string) string { + s = strings.ReplaceAll(s, "&", " & ") + if strings.ContainsRune(s, '@') { + return "```\n" + strings.ReplaceAll(s, "@", "\n@\n") + "```" + } + return "`" + s + "`" +} + +// TODO(zeph): autocomplete +// if we want to backgroundify construction of an autocomplete map, +// use sync.OnceValue and launch a goroutine in main to get the value and discard it