Compare commits
6 Commits
65726eb539
...
128d16a32f
| Author | SHA1 | Date | |
|---|---|---|---|
| 128d16a32f | |||
| d22d032152 | |||
| 2e5f923831 | |||
| 2aebd0144b | |||
| e712263d13 | |||
| f5e26e5036 |
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.sunturtle.xyz/zephyr/horsebot
|
|||||||
go 1.24.1
|
go 1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4
|
git.sunturtle.xyz/zephyr/horse v0.0.0-20260117063336-b22b77c53578
|
||||||
github.com/disgoorg/disgo v0.19.0-rc.15
|
github.com/disgoorg/disgo v0.19.0-rc.15
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4 h1:YJEGZG/EnxE5Tr6EZMGxikXDF6QKKHrH9Bp1dLoWXkk=
|
git.sunturtle.xyz/zephyr/horse v0.0.0-20260117063336-b22b77c53578 h1:FI54vRfCtesXfcwi4oHo86nB/x/2T+ZrZvyFVKiQSYA=
|
||||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk=
|
git.sunturtle.xyz/zephyr/horse v0.0.0-20260117063336-b22b77c53578/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU=
|
||||||
|
|||||||
81
main.go
81
main.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -18,20 +19,41 @@ import (
|
|||||||
"github.com/disgoorg/disgo/discord"
|
"github.com/disgoorg/disgo/discord"
|
||||||
"github.com/disgoorg/disgo/handler"
|
"github.com/disgoorg/disgo/handler"
|
||||||
"github.com/disgoorg/disgo/handler/middleware"
|
"github.com/disgoorg/disgo/handler/middleware"
|
||||||
|
"github.com/disgoorg/disgo/httpserver"
|
||||||
"github.com/disgoorg/disgo/rest"
|
"github.com/disgoorg/disgo/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
appID string
|
|
||||||
pubkey string
|
|
||||||
tokenFile string
|
tokenFile string
|
||||||
|
// http api options
|
||||||
|
addr string
|
||||||
|
route string
|
||||||
|
pubkey string
|
||||||
|
// logging options
|
||||||
|
level slog.Level
|
||||||
|
textfmt 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.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()
|
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)
|
token, err := os.ReadFile(tokenFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("reading token", slog.Any("err", err))
|
slog.Error("reading token", slog.Any("err", err))
|
||||||
@@ -48,11 +70,28 @@ func main() {
|
|||||||
r.Route("/skill", func(r handler.Router) {
|
r.Route("/skill", func(r handler.Router) {
|
||||||
r.SlashCommand("/", skillHandler)
|
r.SlashCommand("/", skillHandler)
|
||||||
r.Autocomplete("/", skillAutocomplete)
|
r.Autocomplete("/", skillAutocomplete)
|
||||||
// TODO(zeph): button handler
|
r.ButtonComponent("/{id}", skillButton)
|
||||||
})
|
})
|
||||||
|
|
||||||
slog.Info("connect", slog.String("disgo", disgo.Version))
|
opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)}
|
||||||
client, err := disgo.New(string(token), 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),
|
||||||
|
slog.Bool("http", addr != ""),
|
||||||
|
slog.String("address", addr),
|
||||||
|
slog.String("route", route),
|
||||||
|
)
|
||||||
|
client, err := disgo.New(string(token), opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("building bot", slog.Any("err", err))
|
slog.Error("building bot", slog.Any("err", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -94,15 +133,14 @@ var commands = []discord.ApplicationCommandCreate{
|
|||||||
func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error {
|
func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error {
|
||||||
q := data.String("query")
|
q := data.String("query")
|
||||||
id, err := strconv.ParseInt(q, 10, 32)
|
id, err := strconv.ParseInt(q, 10, 32)
|
||||||
var s horse.Skill
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// note inverted condition; this is when we have an id
|
// note inverted condition; this is when we have an id
|
||||||
s = global.AllSkills[horse.SkillID(id)]
|
id = int64(global.AllSkills[horse.SkillID(id)].ID)
|
||||||
}
|
}
|
||||||
if s.ID == 0 {
|
if id == 0 {
|
||||||
// Either we weren't given a number or the number doesn't match any skill ID.
|
// Either we weren't given a number or the number doesn't match any skill ID.
|
||||||
id, ok := global.SkillNameToID[q]
|
v := global.SkillNameToID[q]
|
||||||
if !ok {
|
if v == 0 {
|
||||||
// No such skill.
|
// No such skill.
|
||||||
m := discord.MessageCreate{
|
m := discord.MessageCreate{
|
||||||
Content: "No such skill.",
|
Content: "No such skill.",
|
||||||
@@ -110,11 +148,11 @@ func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEv
|
|||||||
}
|
}
|
||||||
return e.CreateMessage(m)
|
return e.CreateMessage(m)
|
||||||
}
|
}
|
||||||
s = global.AllSkills[id]
|
id = int64(v)
|
||||||
}
|
}
|
||||||
// TODO(zeph): search conditions and effects, give a list
|
// TODO(zeph): search conditions and effects, give a list
|
||||||
m := discord.MessageCreate{
|
m := discord.MessageCreate{
|
||||||
Components: []discord.LayoutComponent{RenderSkill(s)},
|
Components: []discord.LayoutComponent{RenderSkill(horse.SkillID(id), global.AllSkills, global.SkillGroups)},
|
||||||
Flags: discord.MessageFlagIsComponentsV2,
|
Flags: discord.MessageFlagIsComponentsV2,
|
||||||
}
|
}
|
||||||
return e.CreateMessage(m)
|
return e.CreateMessage(m)
|
||||||
@@ -133,3 +171,18 @@ func skillAutocomplete(e *handler.AutocompleteEvent) error {
|
|||||||
}
|
}
|
||||||
return e.AutocompleteResult(r)
|
return e.AutocompleteResult(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
91
skill.go
91
skill.go
@@ -8,54 +8,45 @@ import (
|
|||||||
"github.com/disgoorg/disgo/discord"
|
"github.com/disgoorg/disgo/discord"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RenderSkill(s horse.Skill) discord.ContainerComponent {
|
func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map[int32][4]horse.SkillID) discord.ContainerComponent {
|
||||||
skilltype := discord.TextDisplayComponent{
|
s, ok := all[id]
|
||||||
ID: 4,
|
if !ok {
|
||||||
Content: "Skill Issue",
|
return discord.NewContainer(discord.NewTextDisplayf("invalid skill ID %v made it to RenderSkill", id))
|
||||||
}
|
|
||||||
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)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thumburl := fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID)
|
||||||
|
r := discord.NewContainer(discord.NewSection(discord.NewTextDisplayf("## %s\n%s", s.Name, s.Description)).WithAccessory(discord.NewThumbnail(thumburl)))
|
||||||
|
var skilltype string
|
||||||
switch {
|
switch {
|
||||||
case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5:
|
case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5:
|
||||||
// unique of various star levels
|
// unique of various star levels
|
||||||
r.AccentColor = 0xaca4d4
|
r.AccentColor = 0xaca4d4
|
||||||
skilltype.Content = "Unique Skill"
|
skilltype = "Unique Skill"
|
||||||
case s.Rarity == 2:
|
case s.Rarity == 2:
|
||||||
// rare (gold)
|
// rare (gold)
|
||||||
r.AccentColor = 0xd7c25b
|
r.AccentColor = 0xd7c25b
|
||||||
skilltype.Content = "Rare Skill"
|
skilltype = "Rare Skill"
|
||||||
case s.GroupRate == -1:
|
case s.GroupRate == -1:
|
||||||
// negative (purple) skill
|
// negative (purple) skill
|
||||||
r.AccentColor = 0x9151d4
|
r.AccentColor = 0x9151d4
|
||||||
skilltype.Content = "Negative Skill"
|
skilltype = "Negative Skill"
|
||||||
case !s.WitCheck:
|
case !s.WitCheck:
|
||||||
// should be passive (green)
|
// should be passive (green)
|
||||||
r.AccentColor = 0x66ae1c
|
r.AccentColor = 0x66ae1c
|
||||||
skilltype.Content = "Passive Skill"
|
skilltype = "Passive Skill"
|
||||||
// TODO(zeph): debuff (red)
|
case isDebuff(s):
|
||||||
|
// debuff (red)
|
||||||
|
r.AccentColor = 0xe34747
|
||||||
|
skilltype = "Debuff Skill"
|
||||||
case s.Rarity == 1:
|
case s.Rarity == 1:
|
||||||
// common (white)
|
// common (white)
|
||||||
r.AccentColor = 0xcccccc
|
r.AccentColor = 0xcccccc
|
||||||
skilltype.Content = "Common Skill"
|
skilltype = "Common Skill"
|
||||||
}
|
}
|
||||||
r.Components = append(r.Components, discord.SeparatorComponent{ID: 5, Spacing: discord.SeparatorSpacingSizeSmall})
|
r.Components = append(r.Components, discord.NewSmallSeparator())
|
||||||
text := make([]string, 0, 3)
|
text := make([]string, 0, 3)
|
||||||
abils := make([]string, 0, 3)
|
abils := make([]string, 0, 3)
|
||||||
for i, act := range s.Activations {
|
for _, act := range s.Activations {
|
||||||
text, abils = text[:0], abils[:0]
|
text, abils = text[:0], abils[:0]
|
||||||
if act.Precondition != "" {
|
if act.Precondition != "" {
|
||||||
text = append(text, "Precondition: "+formatCondition(act.Precondition))
|
text = append(text, "Precondition: "+formatCondition(act.Precondition))
|
||||||
@@ -70,27 +61,36 @@ func RenderSkill(s horse.Skill) discord.ContainerComponent {
|
|||||||
case act.Duration >= 500e4:
|
case act.Duration >= 500e4:
|
||||||
t = "Permanent "
|
t = "Permanent "
|
||||||
default:
|
default:
|
||||||
t = act.Duration.String() + "s "
|
t = "For " + act.Duration.String() + "s, "
|
||||||
}
|
}
|
||||||
for _, a := range act.Abilities {
|
for _, a := range act.Abilities {
|
||||||
abils = append(abils, a.String())
|
abils = append(abils, a.String())
|
||||||
}
|
}
|
||||||
t += strings.Join(abils, ", ")
|
t += strings.Join(abils, ", ")
|
||||||
if act.Cooldown < 500e4 {
|
if act.Cooldown > 0 && act.Cooldown < 500e4 {
|
||||||
t += " on " + act.Cooldown.String() + "s cooldown"
|
t += " on " + act.Cooldown.String() + "s cooldown"
|
||||||
}
|
}
|
||||||
text = append(text, t)
|
text = append(text, t)
|
||||||
r.Components = append(r.Components, discord.TextDisplayComponent{
|
r.Components = append(r.Components, discord.NewTextDisplay(strings.Join(text, "\n")))
|
||||||
ID: 10 + i,
|
}
|
||||||
Content: strings.Join(text, "\n"),
|
r.Components = append(r.Components, discord.NewSmallSeparator(), discord.NewTextDisplayf("%s ・ SP cost %d ・ Grade value %d", skilltype, s.SPCost, s.GradeValue))
|
||||||
})
|
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...))
|
||||||
}
|
}
|
||||||
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +102,17 @@ func formatCondition(s string) string {
|
|||||||
return "`" + s + "`"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(zeph): autocomplete
|
// TODO(zeph): autocomplete
|
||||||
// if we want to backgroundify construction of an autocomplete map,
|
// 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
|
// use sync.OnceValue and launch a goroutine in main to get the value and discard it
|
||||||
|
|||||||
Reference in New Issue
Block a user