cmd/horsebot: move to here
This commit is contained in:
1
cmd/horsebot/.gitignore
vendored
Normal file
1
cmd/horsebot/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
token
|
||||
10
cmd/horsebot/README.md
Normal file
10
cmd/horsebot/README.md
Normal file
@@ -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.
|
||||
56
cmd/horsebot/autocomplete/autocomplete.go
Normal file
56
cmd/horsebot/autocomplete/autocomplete.go
Normal file
@@ -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") })
|
||||
70
cmd/horsebot/autocomplete/autocomplete_test.go
Normal file
70
cmd/horsebot/autocomplete/autocomplete_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
cmd/horsebot/log.go
Normal file
55
cmd/horsebot/log.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
183
cmd/horsebot/main.go
Normal file
183
cmd/horsebot/main.go
Normal file
@@ -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)
|
||||
}
|
||||
148
cmd/horsebot/skill.go
Normal file
148
cmd/horsebot/skill.go
Normal file
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user