package main import ( "context" "database/sql" "errors" "flag" "fmt" "net/http" "os" "os/signal" "sort" "strconv" "strings" "syscall" "time" "github.com/bwmarrin/discordgo" _ "github.com/mattn/go-sqlite3" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "go.wperron.io/themis" ) const ( CONN_STRING_PATTERN = "file:%s?cache=shared&mode=rw&_journal_mode=WAL" ) var ( dbFile = flag.String("db", "", "SQlite database file path.") debug = flag.Bool("debug", false, "Set log level to DEBUG.") store *themis.Store ) type Handler func(s *discordgo.Session, i *discordgo.InteractionCreate) func main() { log.Info().Msg("startup.") start := time.Now() ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGKILL, syscall.SIGINT) defer cancel() flag.Parse() zerolog.SetGlobalLevel(zerolog.InfoLevel) if *debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) } log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout}) zerolog.DurationFieldUnit = time.Millisecond go func() { if err := serve(":8080"); err != nil { log.Error().Err(err).Msg("failed to serve requests") } cancel() }() err := touchDbFile(*dbFile) if err != nil { log.Fatal().Err(err).Msg("failed to touch database file") } connString := fmt.Sprintf(CONN_STRING_PATTERN, *dbFile) store, err = themis.NewStore(connString) if err != nil { log.Fatal().Err(err).Msg("failed to initialize database") } defer store.Close() notifChan := make(chan struct{}) notifier := themis.NewNotifier(notifChan) go notifier.Start(ctx) authToken, ok := os.LookupEnv("DISCORD_TOKEN") if !ok { log.Fatal().Err(err).Msg("no auth token found at DISCORD_TOKEN env var") } appId, ok := os.LookupEnv("DISCORD_APP_ID") if !ok { log.Fatal().Err(err).Msg("no app id found at DISCORD_APP_ID env var") } guildId, ok := os.LookupEnv("DISCORD_GUILD_ID") if !ok { log.Fatal().Err(err).Msg("no guild id found at DISCORD_GUILD_ID env var") } channelId, ok := os.LookupEnv("DISCORD_BOT_CHANNEL_ID") if !ok { log.Fatal().Err(err).Msg("no channel id found at DISCORD_BOT_CHANNEL_ID env var") } discord, err := discordgo.New(fmt.Sprintf("Bot %s", authToken)) if err != nil { log.Fatal().Err(err).Msg("failed to initialize discord session") } log.Info().Str("app_id", appId).Str("guild_id", guildId).Msg("connected to discord") commands := []*discordgo.ApplicationCommand{ // Server info commands { Name: "info", Description: "Server Information", Type: discordgo.ChatApplicationCommand, }, // EU4 claims commands { Name: "list-claims", Description: "List current claims", Type: discordgo.ChatApplicationCommand, }, { Name: "claim", Description: "Take a claim on provinces", Type: discordgo.ChatApplicationCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "claim-type", Description: "one of `area`, `region` or `trade`", Type: discordgo.ApplicationCommandOptionString, Choices: []*discordgo.ApplicationCommandOptionChoice{ {Name: "Area", Value: themis.CLAIM_TYPE_AREA}, {Name: "Region", Value: themis.CLAIM_TYPE_REGION}, {Name: "Trade Node", Value: themis.CLAIM_TYPE_TRADE}, }, }, { Name: "name", Description: "the name of zone claimed", Type: discordgo.ApplicationCommandOptionString, Autocomplete: true, }, }, }, { Name: "describe-claim", Description: "Get details on a claim", Type: discordgo.ChatApplicationCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "id", Description: "Numerical ID for the claim", Type: discordgo.ApplicationCommandOptionInteger, }, }, }, { Name: "delete-claim", Description: "Release one of your claims", Type: discordgo.ChatApplicationCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "id", Description: "numerical ID for the claim", Type: discordgo.ApplicationCommandOptionInteger, }, }, }, { Name: "flush", Description: "Remove all claims from the database and prepare for the next game!", Type: discordgo.ChatApplicationCommand, }, { Name: "query", Description: "Run a raw SQL query on the database", Type: discordgo.ChatApplicationCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "query", Description: "Raw SQL query", Type: discordgo.ApplicationCommandOptionString, }, }, }, // Scheduling commands { Name: "schedule", Description: "Get the schedule for the following weeks.", Type: discordgo.ChatApplicationCommand, }, { Name: "send-schedule", Description: "Trigger the scheduled message. Admins only", Type: discordgo.ChatApplicationCommand, DefaultMemberPermissions: new(int64), // default 0 == admins only }, { Name: "absent", Description: "Mark yourself as absent for a session", Type: discordgo.ChatApplicationCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "date", Required: false, Description: "Date of the session you can't make it to. YYYY-MM-DD format.", Type: discordgo.ApplicationCommandOptionString, }, }, }, } handlers := map[string]Handler{ "info": func(s *discordgo.Session, i *discordgo.InteractionCreate) { uptime, err := themis.Uptime() if err != nil { err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Oops, something went wrong! :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to get server uptime") return } claimCount, uniquePlayers, err := store.CountClaims(ctx) if err != nil { err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Oops, something went wrong! :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to count claims") return } ev, err := store.LastOf(ctx, themis.EventFlush) var lastFlush string if err != nil { if err != themis.ErrNever { err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Oops, something went wrong! :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed get last flush event") return } lastFlush = "never" } else { lastFlush = ev.Timestamp.Format(time.DateTime) } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Server has been up for %s, has %d claims from %d unique players.\nThe last time claims were flushed was: %s.", uptime, claimCount, uniquePlayers, lastFlush), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "list-claims": func(s *discordgo.Session, i *discordgo.InteractionCreate) { claims, err := store.ListClaims(ctx) if err != nil { err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Oops, something went wrong! :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to list claims") return } sb := strings.Builder{} sb.WriteString(fmt.Sprintf("There are currently %d claims:\n", len(claims))) sb.WriteString("```\n") sb.WriteString(formatClaimsTable(claims)) sb.WriteString("```\n") err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: sb.String(), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type == discordgo.InteractionApplicationCommandAutocomplete { log.Debug().Msg("command type interaction autocomplete") handleClaimAutocomplete(ctx, store, s, i) return } opts := i.ApplicationCommandData().Options if len(opts) != 2 { err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "`claim-type` and `name` are mandatory parameters", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } claimType, err := themis.ClaimTypeFromString(opts[0].StringValue()) if err != nil { err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "You can only take claims of types `area`, `region` or `trade`", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Str("claim_type", opts[0].StringValue()).Msg("failed to parse claim") return } name := opts[1].StringValue() player := i.Member.Nick if player == "" { player = i.Member.User.Username } userId := i.Member.User.ID _, err = store.Claim(ctx, userId, player, name, claimType) if err != nil { conflict, ok := err.(themis.ErrConflict) if ok { sb := strings.Builder{} sb.WriteString("Some provinces are already claimed:\n```\n") for _, c := range conflict.Conflicts { sb.WriteString(fmt.Sprintf(" - %s\n", c)) } sb.WriteString("```\n") err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: sb.String(), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "failed to acquire claim :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to acquire claim") return } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Claimed %s for %s!", name, player), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "describe-claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) { id := i.ApplicationCommandData().Options[0] detail, err := store.DescribeClaim(ctx, int(id.IntValue())) if err != nil { err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "woops, something went wrong :(", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to describe claim") return } sb := strings.Builder{} sb.WriteString(fmt.Sprintf("#%d %s %s (%s)\n", detail.ID, detail.Name, detail.Type, detail.Player)) for _, p := range detail.Provinces { sb.WriteString(fmt.Sprintf(" - %s\n", p)) } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: sb.String(), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "delete-claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) { id := i.ApplicationCommandData().Options[0] userId := i.Member.User.ID err := store.DeleteClaim(ctx, int(id.IntValue()), userId) if err != nil { msg := "Oops, something went wrong :( blame @wperron" if errors.Is(err, themis.ErrNoSuchClaim) { msg = fmt.Sprintf("Claim #%d not found for %s", id.IntValue(), i.Member.Nick) } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: msg, }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to delete claim") return } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Got it chief.", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "flush": func(s *discordgo.Session, i *discordgo.InteractionCreate) { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseModal, Data: &discordgo.InteractionResponseData{ CustomID: "modals_flush_" + i.Interaction.Member.User.ID, Title: "Are you sure?", Components: []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.TextInput{ CustomID: "confirmation", Label: "Delete all claims permanently? [y/N]", Style: discordgo.TextInputShort, Placeholder: "", Value: "", Required: true, MinLength: 1, MaxLength: 45, }, }, }, }, }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "query": func(s *discordgo.Session, i *discordgo.InteractionCreate) { roDB, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=private&mode=ro", *dbFile)) if err != nil { log.Error().Err(err).Msg("failed to open read-only copy of database") return } q := i.ApplicationCommandData().Options[0].StringValue() deadlined, cancelDeadline := context.WithTimeout(ctx, 15*time.Second) defer cancelDeadline() rows, err := roDB.QueryContext(deadlined, q) if err != nil { log.Error().Err(err).Msg("failed to exec user-provided query") return } fmtd, err := themis.FormatRows(rows) if err != nil { log.Error().Err(err).Msg("failed to format rows") return } // 2000 is a magic number here, it's the character limit for a discord // message, we're cutting slightly under that to allow the backticks // for the monospaced block. table := fmt.Sprintf("```\n%s\n```", fmtd[:min(len(fmtd), 1990)]) if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: table, }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "schedule": func(s *discordgo.Session, i *discordgo.InteractionCreate) { // get schedule from now to 4 mondays into the future sched, err := store.GetSchedule(ctx, themis.NextMonday(), themis.NextMonday().Add(4*7*24*time.Hour)) if err != nil { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "failed to get schedule, check logs for more info.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } log.Error().Err(err).Msg("failed to get schedule") return } sb := strings.Builder{} keys := make([]string, 0, len(sched)) for k, abs := range sched { for i, a := range abs { a = fmt.Sprintf("<@%s>", a) abs[i] = a } keys = append(keys, k) } sort.Strings(keys) for _, d := range keys { sb.WriteString(d + ": ") if len(sched[d]) == 0 { sb.WriteString("Everyone is available!\n") } else { sb.WriteString(themis.FormatStringSlice(sched[d]) + " won't be able to make it\n") } } if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: sb.String(), }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "send-schedule": func(s *discordgo.Session, i *discordgo.InteractionCreate) { notifier.Send() if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Done.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, "absent": func(s *discordgo.Session, i *discordgo.InteractionCreate) { var rawDate string if len(i.ApplicationCommandData().Options) == 0 { rawDate = themis.NextMonday().Format(time.DateOnly) } else { rawDate = i.ApplicationCommandData().Options[0].StringValue() } date, err := time.Parse(time.DateOnly, rawDate) if err != nil { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "failed to parse provided date, make sure to use the YYYY-MM-DD format.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } if date.Before(time.Now()) { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "The date must be some time in the future.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } if date.Weekday() != time.Monday { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "The date you provided is not a Monday.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } // TODO(wperron) suggest Mondays before and after? return } userId := i.Member.User.ID if err := store.AddAbsence(ctx, date, userId); err != nil { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "something went wrong recording your absence, check logs for more info.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Okey dokey.", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } }, } registerHandlers(discord, handlers) err = discord.Open() if err != nil { log.Fatal().Err(err).Msg("failed to open discord websocket") } defer discord.Close() log.Debug().Int("count", len(commands)).Msg("registering commands via bulk overwrite") created, err := discord.ApplicationCommandBulkOverwrite(appId, guildId, commands) if err != nil { log.Fatal().Err(err).Msg("failed to register commands with discord") } log.Info().Int("count", len(created)).Dur("startup_latency_ms", time.Since(start)).Msg("registered commands, ready to operate") go notifier.NotifyFunc(ctx, func() { log.Info().Msg("sending weekly reminder") absentees, err := store.GetAbsentees(ctx, themis.NextMonday()) if err != nil { log.Error().Err(err).Msg("failed to get absentees for next session") return } for i, a := range absentees { a = fmt.Sprintf("<@%s>", a) absentees[i] = a } var msg string var components []discordgo.MessageComponent if len(absentees) == 0 { msg = "Everybody can make it next Monday, see you then!" components = []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.Button{ CustomID: "schedule-response", Label: "I Can't Make It", Style: discordgo.DangerButton, }, }, }, } } else { msg = fmt.Sprintf("%s can't make it next Monday. :sad:", themis.FormatStringSlice(absentees)) } _, err = discord.ChannelMessageSendComplex(channelId, &discordgo.MessageSend{ Content: msg, Components: components, }) if err != nil { log.Error().Err(err).Msg("failed to send scheduled notification") } }) <-ctx.Done() log.Info().Msg("context cancelled, exiting") os.Exit(0) } func touchDbFile(path string) error { log.Debug().Str("path", path).Msg("touching database file") f, err := os.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { f, err := os.Create(path) if err != nil { return err } f.Close() } else { return err } } f.Close() return nil } func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) { sess.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { log.Info().Str("user_id", fmt.Sprintf("%s#%s", s.State.User.Username, s.State.User.Discriminator)).Msg("logged in") }) sess.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.Type { case discordgo.InteractionApplicationCommand: if h, ok := handlers[i.ApplicationCommandData().Name]; ok { withLogging(i.ApplicationCommandData().Name, h)(s, i) } case discordgo.InteractionModalSubmit: if strings.HasPrefix(i.ModalSubmitData().CustomID, "modals_flush_") { sub := i.ModalSubmitData().Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value sub = strings.ToLower(sub) log.Debug().Str("value", sub).Msg("flush modal submitted") if sub == "y" || sub == "ye" || sub == "yes" { err := store.Flush(context.Background(), i.Member.User.ID) msg := "Flushed all claims!" if err != nil { log.Error().Err(err).Msg("failed to flush claims") msg = "failed to flush claims from database" } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: msg, }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Aborted...", }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } case discordgo.InteractionMessageComponent: switch i.MessageComponentData().CustomID { case "schedule-response": userId := i.Member.User.ID if err := store.AddAbsence(context.TODO(), themis.NextMonday(), userId); err != nil { if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "something went wrong recording your absence, check logs for more info.", }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } return } err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Looks like <@%s> can't make it after all.", userId), }, }) if err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } } } }) } const TABLE_PATTERN = "| %-*s | %-*s | %-*s | %-*s |\n" func formatClaimsTable(claims []themis.Claim) string { sb := strings.Builder{} maxLengths := []int{2, 6, 4, 4} // id, player, type, name for _, c := range claims { sid := strconv.Itoa(c.ID) if len(sid) > maxLengths[0] { maxLengths[0] = len(sid) } if len(c.Player) > maxLengths[1] { maxLengths[1] = len(c.Player) } // The raw claim value is different from the formatted string strType := c.Type.String() if len(strType) > maxLengths[2] { maxLengths[2] = len(strType) } if len(c.Name) > maxLengths[3] { maxLengths[3] = len(c.Name) } } sb.WriteString(fmt.Sprintf(TABLE_PATTERN, maxLengths[0], "ID", maxLengths[1], "Player", maxLengths[2], "Type", maxLengths[3], "Name")) sb.WriteString(fmt.Sprintf(TABLE_PATTERN, maxLengths[0], strings.Repeat("-", maxLengths[0]), maxLengths[1], strings.Repeat("-", maxLengths[1]), maxLengths[2], strings.Repeat("-", maxLengths[2]), maxLengths[3], strings.Repeat("-", maxLengths[3]))) for _, c := range claims { sb.WriteString(fmt.Sprintf(TABLE_PATTERN, maxLengths[0], strconv.Itoa(c.ID), maxLengths[1], c.Player, maxLengths[2], c.Type, maxLengths[3], c.Name)) } return sb.String() } func handleClaimAutocomplete(ctx context.Context, store *themis.Store, s *discordgo.Session, i *discordgo.InteractionCreate) { log.Debug().Msg("getting autocomplete data for claim") opts := i.ApplicationCommandData().Options claimType, err := themis.ClaimTypeFromString(opts[0].StringValue()) if err != nil { log.Error().Err(err).Msg("failed to parse claim type") return } availability, err := store.ListAvailability(ctx, claimType, opts[1].StringValue()) if err != nil { log.Error().Err(err).Msg("failed to list availabilities") return } choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(availability)) for _, s := range availability { choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ Name: s, Value: s, }) } log.Debug().Int("len", len(choices)).Msg("found autocomplete suggestions") if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionApplicationCommandAutocompleteResult, Data: &discordgo.InteractionResponseData{ Choices: choices[:min(len(choices), 25)], }, }); err != nil { log.Error().Err(err).Msg("failed to respond to interaction") } } func serve(address string) error { http.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) //nolint:errcheck // this is expected to always work, 'trust me bro' guaranteed })) return http.ListenAndServe(address, nil) } func withLogging(name string, f func(s *discordgo.Session, i *discordgo.InteractionCreate)) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { start := time.Now() logCommandInvocation(name, s, i) f(s, i) debugCommandCompletion(name, time.Since(start), s, i) } } func logCommandInvocation(name string, s *discordgo.Session, i *discordgo.InteractionCreate) { log.Info(). Str("userid", i.Member.User.ID). Str("username", i.Member.User.Username). Str("command", name). Str("params", func() string { p := make([]string, 0, len(i.ApplicationCommandData().Options)) for _, o := range i.ApplicationCommandData().Options { p = append(p, o.Name+"="+o.StringValue()) } return strings.Join(p, ", ") }()). Msg("command invoked") } func debugCommandCompletion(name string, dur time.Duration, s *discordgo.Session, i *discordgo.InteractionCreate) { log.Debug(). Str("userid", i.Member.User.ID). Str("username", i.Member.User.Username). Str("command", name). Dur("latency_ms", dur). Msg("command completed") } func min(a, b int) int { if a < b { return a } return b }