diff --git a/internal/bot/bot.go b/internal/bot/bot.go index ed50da0..225c29c 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -24,6 +24,12 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, return nil, err } + // Intents needed for: + // - InteractionCreate (slash commands): Guilds + // - MessageCreate (leveling): GuildMessages + // - VoiceStateUpdate (meeting lobby): GuildVoiceStates + session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates + return &Bot{ Session: session, AppID: appID, @@ -54,6 +60,10 @@ func (b *Bot) Start() error { events.HandleMessageCreate(s, m, b.Services) }) + b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { + events.HandleVoiceStateUpdate(s, vs, b.Services) + }) + return nil } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index bd272fc..a0b84f4 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -4,26 +4,41 @@ import ( "velox-bot/internal/commands/fun" "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" + "velox-bot/internal/commands/meeting" "github.com/bwmarrin/discordgo" ) var AllCommands = []*discordgo.ApplicationCommand{ fun.Ping, + fun.Joke, + fun.Coinflip, + fun.Dice, + fun.Rps, + fun.RpsStats, + fun.RpsLeaderboard, help.Help, cmdlevel.Slvl, cmdlevel.Rank, cmdlevel.Leaderboard, cmdlevel.Rewards, + meeting.Meeting, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "ping": fun.PingHandler, - "help": help.HelpHandler, + "ping": fun.PingHandler, + "joke": fun.JokeHandler, + "coinflip": fun.CoinflipHandler, + "dice": fun.DiceHandler, + "rps": fun.RpsHandler, + "rpsstats": fun.RpsStatsHandler, + "rpsleaderboard": fun.RpsLeaderboardHandler, + "help": help.HelpHandler, "slvl": cmdlevel.SlvlHandler, "rank": cmdlevel.RankHandler, "leaderboard": cmdlevel.LeaderboardHandler, "rewards": cmdlevel.RewardsHandler, + "meeting": meeting.MeetingHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/commands/fun/public/coinflip.go b/internal/commands/fun/public/coinflip.go new file mode 100644 index 0000000..c0f67f3 --- /dev/null +++ b/internal/commands/fun/public/coinflip.go @@ -0,0 +1,22 @@ +package public + +import ( + "math/rand" + "velox-bot/internal/commands/fun/shared" + + "github.com/bwmarrin/discordgo" +) + +var Coinflip = &discordgo.ApplicationCommand{ + Name: "coinflip", + Description: "Flip a coin", +} + +func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + num := rand.Intn(2) + if num == 0 { + shared.Respond(s, i, "Heads!") + } else { + shared.Respond(s, i, "Tails!") + } +} diff --git a/internal/commands/fun/public/dice.go b/internal/commands/fun/public/dice.go new file mode 100644 index 0000000..3015718 --- /dev/null +++ b/internal/commands/fun/public/dice.go @@ -0,0 +1,62 @@ +package public + +import ( + "strings" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/diceexpr" + + "github.com/bwmarrin/discordgo" +) + +var Dice = &discordgo.ApplicationCommand{ + Name: "dice", + Description: "Roll dice expressions (e.g. d20, 2d20+1, 2#d20+1)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "expr", + Description: "Dice expression to roll", + Required: true, + }, + }, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + discordgo.InteractionContextBotDM, + discordgo.InteractionContextPrivateChannel, + }, +} + +func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + expr := "" + if opts := i.ApplicationCommandData().Options; len(opts) > 0 { + expr = opts[0].StringValue() + } + expr = strings.TrimSpace(expr) + if expr == "" { + respondDiceFailure(s, i) + return + } + + res, err := diceexpr.EvalMany(expr, diceexpr.DefaultLimits()) + if err != nil { + respondDiceFailure(s, i) + return + } + + out := diceexpr.FormatResult(res, diceexpr.DefaultFormatOptions()) + if len(out) > 1900 { + // Keep within Discord message limits; trim safely. + out = out[:1900] + "…" + } + shared.Respond(s, i, out) +} + +func respondDiceFailure(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID != "" { + shared.RespondEphemeral(s, i, "Failure!") + return + } + shared.Respond(s, i, "Failure!") +} + diff --git a/internal/commands/fun/public/joke.go b/internal/commands/fun/public/joke.go new file mode 100644 index 0000000..6bb9a47 --- /dev/null +++ b/internal/commands/fun/public/joke.go @@ -0,0 +1,153 @@ +package public + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "velox-bot/internal/commands/fun/shared" + + "github.com/bwmarrin/discordgo" +) + +var Joke = &discordgo.ApplicationCommand{ + Name: "joke", + Description: "Get a random joke", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + discordgo.InteractionContextBotDM, + discordgo.InteractionContextPrivateChannel, + }, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "type", + Description: "The type of joke to get", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Programming", + Value: "Programming", + }, + { + Name: "Misc", + Value: "Miscellaneous", + }, + { + Name: "Dark", + Value: "Dark", + }, + { + Name: "Pun", + Value: "Pun", + }, + { + Name: "Spooky", + Value: "Spooky", + }, + { + Name: "Christmas", + Value: "Christmas", + }, + }, + }, + }, +} + +func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + jokeType := "Any" + if opts := i.ApplicationCommandData().Options; len(opts) > 0 { + if v := opts[0].StringValue(); v != "" { + jokeType = v + } + } + if !isValidJokeType(jokeType) { + jokeType = "Any" + } + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + content, err := fetchJoke(ctx, jokeType) + if err != nil { + if i.GuildID != "" { + shared.RespondEphemeral(s, i, "Failed to get joke.") + } else { + shared.Respond(s, i, "Failed to get joke.") + } + return + } + + shared.Respond(s, i, content) +} + +func isValidJokeType(t string) bool { + switch t { + case "Any", "Programming", "Misc", "Dark", "Pun", "Spooky", "Christmas": + return true + default: + return false + } +} + +type jokeAPIResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + Type string `json:"type"` + Joke string `json:"joke"` + Setup string `json:"setup"` + Delivery string `json:"delivery"` +} + +func fetchJoke(ctx context.Context, jokeType string) (string, error) { + const baseURL = "https://v2.jokeapi.dev/joke" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", baseURL, jokeType), nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("joke api status: %s", resp.Status) + } + + var data jokeAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if data.Error { + if data.Message != "" { + return "", errors.New(data.Message) + } + return "", errors.New("joke api returned error") + } + + switch data.Type { + case "single": + if data.Joke == "" { + return "", errors.New("empty joke") + } + return data.Joke, nil + case "twopart": + if data.Setup == "" && data.Delivery == "" { + return "", errors.New("empty joke") + } + if data.Setup == "" { + return data.Delivery, nil + } + if data.Delivery == "" { + return data.Setup, nil + } + return data.Setup + "\n" + data.Delivery, nil + default: + return "", fmt.Errorf("unknown joke type: %q", data.Type) + } +} diff --git a/internal/commands/fun/ping.go b/internal/commands/fun/public/ping.go similarity index 96% rename from internal/commands/fun/ping.go rename to internal/commands/fun/public/ping.go index 2810ca5..f227c8d 100644 --- a/internal/commands/fun/ping.go +++ b/internal/commands/fun/public/ping.go @@ -1,4 +1,4 @@ -package fun +package public import "github.com/bwmarrin/discordgo" diff --git a/internal/commands/fun/public/rps.go b/internal/commands/fun/public/rps.go new file mode 100644 index 0000000..a3ae1df --- /dev/null +++ b/internal/commands/fun/public/rps.go @@ -0,0 +1,106 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + rpssvc "velox-bot/internal/db/services/rps" + + "github.com/bwmarrin/discordgo" +) + +var Rps = &discordgo.ApplicationCommand{ + Name: "rps", + Description: "Plays Rock Paper Scissors with you", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "hand", + Description: "Choose between ✌️, ✋ or 🤜", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "✌️ - Scissors", Value: string(rpssvc.HandScissors)}, + {Name: "✋ - Paper", Value: string(rpssvc.HandPaper)}, + {Name: "🤜 - Rock", Value: string(rpssvc.HandRock)}, + }, + }, + }, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + handStr := data.Options[0].StringValue() + userHand, ok := rpssvc.ParseHand(handStr) + if !ok { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + out, err := services.Global.RPS.Play(context.Background(), guildID, userID, userHand) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to play RPS.") + return + } + + color := 0xEF4444 + switch out.Result { + case rpssvc.ResultWin: + color = 0x22C55E + case rpssvc.ResultTie: + color = 0xF59E0B + } + + embed := &discordgo.MessageEmbed{ + Title: string(out.Result), + Description: user.Mention() + " vs bot", + Color: color, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Your hand", Value: out.UserHand.String(), Inline: true}, + {Name: "Bot hand", Value: out.BotHand.String(), Inline: true}, + {Name: "Score", Value: strconv.Itoa(out.Score), Inline: true}, + }, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} diff --git a/internal/commands/fun/public/rpsleaderboard.go b/internal/commands/fun/public/rpsleaderboard.go new file mode 100644 index 0000000..40c27c5 --- /dev/null +++ b/internal/commands/fun/public/rpsleaderboard.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsLeaderboard = &discordgo.ApplicationCommand{ + Name: "rpsleaderboard", + Description: "Shows the RPS leaderboard", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + top, err := services.Global.RPS.TopScores(context.Background(), guildID, 10) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load leaderboard.") + return + } + if len(top) == 0 { + shared.RespondEphemeral(s, i, "No leaderboard data yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(top)) + for idx, entry := range top { + display := fmt.Sprintf("<@%d>", entry.UserID) + if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { + display = u.Username + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("%d. %s", idx+1, display), + Value: fmt.Sprintf("Score: **%d**", entry.Score), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Leaderboard", + Description: fmt.Sprintf("Top %d players in this server", len(top)), + Color: 0xA855F7, + Fields: fields, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/public/rpsstats.go b/internal/commands/fun/public/rpsstats.go new file mode 100644 index 0000000..9eac696 --- /dev/null +++ b/internal/commands/fun/public/rpsstats.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsStats = &discordgo.ApplicationCommand{ + Name: "rpsstats", + Description: "Shows your RPS stats", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + score, ok, err := services.Global.RPS.GetScore(context.Background(), guildID, userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load stats.") + return + } + if !ok { + shared.RespondEphemeral(s, i, "You haven't played RPS yet!") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Stats", + Description: user.Mention(), + Color: 0x3B82F6, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Score", Value: strconv.Itoa(score), Inline: true}, + }, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/registry.go b/internal/commands/fun/registry.go new file mode 100644 index 0000000..410efa9 --- /dev/null +++ b/internal/commands/fun/registry.go @@ -0,0 +1,33 @@ +package fun + +import ( + "velox-bot/internal/commands/fun/public" + + "github.com/bwmarrin/discordgo" +) + +// Commands +var ( + Ping *discordgo.ApplicationCommand = public.Ping + Joke *discordgo.ApplicationCommand = public.Joke + Coinflip *discordgo.ApplicationCommand = public.Coinflip + Dice *discordgo.ApplicationCommand = public.Dice + Rps *discordgo.ApplicationCommand = public.Rps + RpsStats *discordgo.ApplicationCommand = public.RpsStats + RpsLeaderboard *discordgo.ApplicationCommand = public.RpsLeaderboard +) + +// Handlers +func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PingHandler(s, i) } +func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.JokeHandler(s, i) } +func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.CoinflipHandler(s, i) +} +func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.DiceHandler(s, i) } +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RpsHandler(s, i) } +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsStatsHandler(s, i) +} +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsLeaderboardHandler(s, i) +} diff --git a/internal/commands/fun/shared/shared.go b/internal/commands/fun/shared/shared.go new file mode 100644 index 0000000..34be049 --- /dev/null +++ b/internal/commands/fun/shared/shared.go @@ -0,0 +1,24 @@ +package shared + +import ( + "github.com/bwmarrin/discordgo" +) + +func Respond(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + }, + }) +} + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 346c845..b259579 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -47,12 +47,36 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { Fields: []*discordgo.MessageEmbedField{ { Name: "Fun Commands", - Value: "Here are the fun commands for the bot.", + Value: "Small utility + games.", }, { Name: "/ping", Value: "Pong!", }, + { + Name: "/joke [type]", + Value: "Get a random joke (optional category).", + }, + { + Name: "/coinflip", + Value: "Flip a coin.", + }, + { + Name: "/dice ", + Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).", + }, + { + Name: "/rps ", + Value: "Play Rock Paper Scissors (server only).", + }, + { + Name: "/rpsstats", + Value: "Show your RPS score (server only).", + }, + { + Name: "/rpsleaderboard", + Value: "Show top RPS scores (server only).", + }, { Name: "/help", Value: "Get help with the bot.", @@ -69,6 +93,30 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { Name: "/slvl set", Value: "Set the level of a user.", }, + { + Name: "/slvl set-channel", + Value: "Set the channel for level-up messages.", + }, + { + Name: "/slvl set-message", + Value: "Set the custom level-up message.", + }, + { + Name: "/slvl set-reward", + Value: "Set a role reward for a level.", + }, + { + Name: "/rank [user]", + Value: "Show a rank card (server only).", + }, + { + Name: "/leaderboard", + Value: "Show top levels (server only).", + }, + { + Name: "/rewards", + Value: "List configured level rewards (server only).", + }, }, Author: &author, } diff --git a/internal/commands/level/registry.go b/internal/commands/level/registry.go index 7a2af42..1ba2da2 100644 --- a/internal/commands/level/registry.go +++ b/internal/commands/level/registry.go @@ -16,8 +16,11 @@ var ( ) // Handlers -func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(s, i) } -func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.LeaderboardHandler(s, i) } -func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RewardsHandler(s, i) } -func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) } - +func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(s, i) } +func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.LeaderboardHandler(s, i) +} +func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RewardsHandler(s, i) +} +func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) } diff --git a/internal/commands/meeting/public/meeting.go b/internal/commands/meeting/public/meeting.go new file mode 100644 index 0000000..8c26065 --- /dev/null +++ b/internal/commands/meeting/public/meeting.go @@ -0,0 +1,332 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/meeting/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Meeting = &discordgo.ApplicationCommand{ + Name: "meeting", + Description: "Meeting room settings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-lobby", + Description: "Set the voice lobby channel used to create meetings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Voice channel lobby", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock", + Description: "Lock your current meeting room (block others from joining)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock-private", + Description: "Lock and hide your meeting room (block joining and visibility)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "unlock", + Description: "Unlock your current meeting room (allow others to join)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "invite", + Description: "Allow a user to join your locked meeting room", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to allow", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "uninvite", + Description: "Remove a previously invited user's permission to join", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to remove", + Required: true, + }, + }, + }, + }, +} + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireManageGuild(s, i) || !shared.RequireMeetingService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "set-lobby": + handleSetLobby(s, i) + case "lock": + handleLock(s, i) + case "lock-private": + handleLockPrivate(s, i) + case "unlock": + handleUnlock(s, i) + case "invite": + handleInvite(s, i) + case "uninvite": + handleUninvite(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + channelOpt, ok := optMap["channel"] + if !ok { + shared.RespondEphemeral(s, i, "Missing channel option.") + return + } + + ch := channelOpt.ChannelValue(s) + if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "Invalid voice channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") + return + } + + shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") +} + +func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, chID64, guildID64, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT for @everyone (guild ID). + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + discordgo.PermissionVoiceConnect, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") + + _ = chID64 + _ = guildID64 +} + +func handleLockPrivate(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT and VIEW_CHANNEL for @everyone (guild ID). + deny := int64(discordgo.PermissionVoiceConnect | discordgo.PermissionViewChannel) + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + deny, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked (private) "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") +} + +func handleUnlock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Remove the @everyone overwrite so defaults apply again. + if err := s.ChannelPermissionDelete(ch.ID, i.GuildID); err != nil { + shared.RespondEphemeral(s, i, "Failed to unlock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Unlocked "+ch.Mention()+".") +} + +func handleInvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Allow CONNECT for the invited user. + if err := s.ChannelPermissionSet( + ch.ID, + u.ID, + discordgo.PermissionOverwriteTypeMember, + discordgo.PermissionVoiceConnect, + 0, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to invite user.") + return + } + + shared.RespondEphemeral(s, i, "Invited "+u.Mention()+" to "+ch.Mention()+".") +} + +func handleUninvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Remove any member-specific overwrite for this user. + if err := s.ChannelPermissionDelete(ch.ID, u.ID); err != nil { + shared.RespondEphemeral(s, i, "Failed to remove invite.") + return + } + + shared.RespondEphemeral(s, i, "Removed "+u.Mention()+" from "+ch.Mention()+".") +} + +func requireOwnTempVoiceChannel(s *discordgo.Session, i *discordgo.InteractionCreate) (channel *discordgo.Channel, channelID64 int64, guildID64 int64, ok bool) { + guildID64, ok = shared.ParseGuildID(s, i) + if !ok { + return nil, 0, 0, false + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return nil, 0, 0, false + } + + voiceChannelID, found := findMemberVoiceChannelID(s, i.GuildID, i.Member.User.ID) + if !found || voiceChannelID == "" { + shared.RespondEphemeral(s, i, "You must be in your meeting voice channel to use this.") + return nil, 0, 0, false + } + + ch, err := s.Channel(voiceChannelID) + if err != nil || ch == nil { + shared.RespondEphemeral(s, i, "Failed to load your voice channel.") + return nil, 0, 0, false + } + if ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "This only works in a voice channel.") + return nil, 0, 0, false + } + + channelID64, err = strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return nil, 0, 0, false + } + + isTemp, err := services.Global.Meeting.IsTempChannel(context.Background(), guildID64, channelID64) + if err != nil || !isTemp { + shared.RespondEphemeral(s, i, "This is not a bot-created meeting room.") + return nil, 0, 0, false + } + + ownerID, ownerOK, err := services.Global.Meeting.GetTempChannelOwner(context.Background(), guildID64, channelID64) + if err != nil || !ownerOK { + shared.RespondEphemeral(s, i, "Failed to verify meeting room owner.") + return nil, 0, 0, false + } + + invokerID64, _ := strconv.ParseInt(i.Member.User.ID, 10, 64) + if invokerID64 == 0 || ownerID != invokerID64 { + shared.RespondEphemeral(s, i, fmt.Sprintf("Only the room creator can do this. (creator: <@%d>)", ownerID)) + return nil, 0, 0, false + } + + return ch, channelID64, guildID64, true +} + +func findMemberVoiceChannelID(s *discordgo.Session, guildID, userID string) (string, bool) { + // Prefer session state cache (discordgo tracks voice states there when IntentsGuildVoiceStates is enabled). + if s != nil && s.State != nil { + if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil { + if vs.ChannelID != "" { + return vs.ChannelID, true + } + return "", false + } + // Fallback to cached guild object (State, not REST). + if g, err := s.State.Guild(guildID); err == nil && g != nil { + for _, st := range g.VoiceStates { + if st != nil && st.UserID == userID { + return st.ChannelID, st.ChannelID != "" + } + } + } + } + return "", false +} + diff --git a/internal/commands/meeting/registry.go b/internal/commands/meeting/registry.go new file mode 100644 index 0000000..f5c630a --- /dev/null +++ b/internal/commands/meeting/registry.go @@ -0,0 +1,14 @@ +package meeting + +import ( + "velox-bot/internal/commands/meeting/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Meeting *discordgo.ApplicationCommand = public.Meeting +) + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MeetingHandler(s, i) } + diff --git a/internal/commands/meeting/shared/shared.go b/internal/commands/meeting/shared/shared.go new file mode 100644 index 0000000..791055f --- /dev/null +++ b/internal/commands/meeting/shared/shared.go @@ -0,0 +1,66 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + RespondEphemeral(s, i, "You don't have permission to manage meeting settings.") + return false + } + return true +} + +func RequireMeetingService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Meeting == nil { + RespondEphemeral(s, i, "Meeting service is not available.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + +func SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return map[string]*discordgo.ApplicationCommandInteractionDataOption{} + } + opts := data.Options[0].Options + m := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(opts)) + for _, opt := range opts { + m[opt.Name] = opt + } + return m +} + diff --git a/internal/db/repos/rpsrepo/repo.go b/internal/db/repos/rpsrepo/repo.go new file mode 100644 index 0000000..47156c4 --- /dev/null +++ b/internal/db/repos/rpsrepo/repo.go @@ -0,0 +1,76 @@ +package rpsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + var score int + err := r.db.QueryRowContext(ctx, ` + SELECT score + FROM rps + WHERE guild_id = $1 AND user_id = $2 + `, guildID, userID).Scan(&score) + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return score, true, nil +} + +func (r *Repo) SetScore(ctx context.Context, guildID int64, userID int64, score int) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO rps (guild_id, user_id, score) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET score = EXCLUDED.score + `, guildID, userID, score) + return err +} + +type ScoreEntry struct { + UserID int64 + Score int +} + +func (r *Repo) TopScores(ctx context.Context, guildID int64, limit int) ([]ScoreEntry, error) { + if limit <= 0 { + limit = 10 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT user_id, score + FROM rps + WHERE guild_id = $1 + ORDER BY score DESC, user_id ASC + LIMIT $2 + `, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]ScoreEntry, 0, limit) + for rows.Next() { + var e ScoreEntry + if err := rows.Scan(&e.UserID, &e.Score); err != nil { + return nil, err + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index 198034c..82035ba 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -19,6 +19,96 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } +func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + const q = ` + INSERT INTO meeting_settings (guild_id, lobby_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) + return err +} + +func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + const q = ` + SELECT lobby_channel_id + FROM meeting_settings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var ch sql.NullInt64 + switch err := row.Scan(&ch); err { + case nil: + if !ch.Valid { + return 0, false, nil + } + return ch.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + const q = ` + INSERT INTO meeting_temp_channels (guild_id, channel_id, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, channel_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID, createdBy) + return err +} + +func (r *Repo) RemoveMeetingTempChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + DELETE FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) IsMeetingTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + const q = ` + SELECT 1 + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + var one int + switch err := row.Scan(&one); err { + case nil: + return true, nil + case sql.ErrNoRows: + return false, nil + default: + return false, err + } +} + +func (r *Repo) GetMeetingTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + const q = ` + SELECT created_by + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + var cb sql.NullInt64 + switch err := row.Scan(&cb); err { + case nil: + if !cb.Valid { + return 0, false, nil + } + return cb.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error { const q = ` INSERT INTO levelsettings (guild_id, levelsys) diff --git a/internal/db/services/meeting/service.go b/internal/db/services/meeting/service.go new file mode 100644 index 0000000..7c2b585 --- /dev/null +++ b/internal/db/services/meeting/service.go @@ -0,0 +1,40 @@ +package meeting + +import ( + "context" + + "velox-bot/internal/db/repos/settingsrepo" +) + +type Service struct { + settings *settingsrepo.Repo +} + +func New(settings *settingsrepo.Repo) *Service { + return &Service{settings: settings} +} + +func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) +} + +func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + return s.settings.GetMeetingLobbyChannel(ctx, guildID) +} + +func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + return s.settings.AddMeetingTempChannel(ctx, guildID, channelID, createdBy) +} + +func (s *Service) RemoveTempChannel(ctx context.Context, guildID, channelID int64) error { + return s.settings.RemoveMeetingTempChannel(ctx, guildID, channelID) +} + +func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + return s.settings.IsMeetingTempChannel(ctx, guildID, channelID) +} + +func (s *Service) GetTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + return s.settings.GetMeetingTempChannelOwner(ctx, guildID, channelID) +} + diff --git a/internal/db/services/rps/service.go b/internal/db/services/rps/service.go new file mode 100644 index 0000000..9bf9f09 --- /dev/null +++ b/internal/db/services/rps/service.go @@ -0,0 +1,136 @@ +package rps + +import ( + "context" + "crypto/rand" + "errors" + "math/big" + "strings" + + "velox-bot/internal/db/repos/rpsrepo" +) + +type Repo interface { + GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) + SetScore(ctx context.Context, guildID int64, userID int64, score int) error + TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) +} + +type Service struct { + repo Repo +} + +func New(repo Repo) *Service { + return &Service{repo: repo} +} + +type Hand string + +const ( + HandScissors Hand = "✌️" + HandPaper Hand = "✋" + HandRock Hand = "🤜" +) + +func (h Hand) String() string { return string(h) } + +func ParseHand(s string) (Hand, bool) { + switch strings.TrimSpace(s) { + case string(HandScissors): + return HandScissors, true + case string(HandPaper): + return HandPaper, true + case string(HandRock): + return HandRock, true + default: + return "", false + } +} + +func RandomHand() (Hand, error) { + n, err := rand.Int(rand.Reader, big.NewInt(3)) + if err != nil { + return "", err + } + switch n.Int64() { + case 0: + return HandScissors, nil + case 1: + return HandPaper, nil + default: + return HandRock, nil + } +} + +type GameResult string + +const ( + ResultWin GameResult = "You won!" + ResultLose GameResult = "You lost!" + ResultTie GameResult = "It's a tie!" +) + +func DetermineResult(user Hand, bot Hand) GameResult { + if user == bot { + return ResultTie + } + switch user { + case HandRock: + if bot == HandScissors { + return ResultWin + } + case HandPaper: + if bot == HandRock { + return ResultWin + } + case HandScissors: + if bot == HandPaper { + return ResultWin + } + } + return ResultLose +} + +type PlayOutput struct { + UserHand Hand + BotHand Hand + Result GameResult + Score int +} + +func (s *Service) Play(ctx context.Context, guildID int64, userID int64, userHand Hand) (PlayOutput, error) { + botHand, err := RandomHand() + if err != nil { + return PlayOutput{}, err + } + result := DetermineResult(userHand, botHand) + + score, _, err := s.repo.GetScore(ctx, guildID, userID) + if err != nil { + return PlayOutput{}, err + } + if result == ResultWin { + score++ + if err := s.repo.SetScore(ctx, guildID, userID, score); err != nil { + return PlayOutput{}, err + } + } + + return PlayOutput{ + UserHand: userHand, + BotHand: botHand, + Result: result, + Score: score, + }, nil +} + +func (s *Service) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + return s.repo.GetScore(ctx, guildID, userID) +} + +func (s *Service) TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) { + return s.repo.TopScores(ctx, guildID, limit) +} + +var ErrGuildOnly = errors.New("guild only") + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index 29d0c97..103def1 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -3,19 +3,25 @@ package services import ( "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/rps" ) type Services struct { Level *level.Service LevelSettings *levelsettings.Service + Meeting *meeting.Service + RPS *rps.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, rps *rps.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, + Meeting: meeting, + RPS: rps, } Global = s return s diff --git a/internal/diceexpr/diceexpr.go b/internal/diceexpr/diceexpr.go new file mode 100644 index 0000000..3452520 --- /dev/null +++ b/internal/diceexpr/diceexpr.go @@ -0,0 +1,744 @@ +package diceexpr + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "unicode" +) + +type Limits struct { + MaxExprLen int + MaxRepeat int + MaxDice int + MaxSides int + MaxAstDepth int + MaxExplosionDepth int +} + +func DefaultLimits() Limits { + return Limits{ + MaxExprLen: 200, + MaxRepeat: 25, + MaxDice: 300, + MaxSides: 1000000, + MaxAstDepth: 64, + MaxExplosionDepth: 100, + } +} + +type EvalResult struct { + Total int + Trace *Trace +} + +type Trace struct { + // Text is a normalized display of the (non-repeat) expression. + Text string + // Rolls includes all dice rolls (including dropped ones) in encounter order. + Rolls []RollGroup +} + +type RollGroup struct { + Count int + Sides int + // Dice is the per-die roll chain; each die may have multiple faces due to explosions. + Dice []Die + // KeepDrop is the modifier used (kh/kl/dh/dl). Empty if none. + KeepDrop string + KeepDropN int +} + +type Die struct { + Faces []int + Kept bool +} + +type MultiResult struct { + Repeat int + Items []EvalResult +} + +func EvalMany(input string, limits Limits) (MultiResult, error) { + input = strings.TrimSpace(input) + if input == "" { + return MultiResult{}, errors.New("empty") + } + if limits.MaxExprLen > 0 && len(input) > limits.MaxExprLen { + return MultiResult{}, errors.New("too long") + } + + repeat, expr := splitRepeat(input) + if repeat < 1 { + repeat = 1 + } + if limits.MaxRepeat > 0 && repeat > limits.MaxRepeat { + return MultiResult{}, fmt.Errorf("repeat too large") + } + + // Parse once, evaluate many times. + p := newParser(expr, limits) + ast, err := p.parse() + if err != nil { + return MultiResult{}, err + } + norm := formatExpr(ast) + + out := MultiResult{Repeat: repeat, Items: make([]EvalResult, 0, repeat)} + for range repeat { + e := evaluator{limits: limits} + total, trace, err := e.eval(ast) + if err != nil { + return MultiResult{}, err + } + trace.Text = norm + out.Items = append(out.Items, EvalResult{Total: total, Trace: trace}) + } + return out, nil +} + +func splitRepeat(s string) (int, string) { + // Accept "R#expr" where R is positive integer. + // We only treat the first '#' as repeat separator if it occurs before any dice operator. + // This keeps "d#" or other oddities from being interpreted as repeat. + i := strings.IndexByte(s, '#') + if i <= 0 { + return 1, s + } + prefix := strings.TrimSpace(s[:i]) + if prefix == "" { + return 1, s + } + for _, r := range prefix { + if !unicode.IsDigit(r) { + return 1, s + } + } + n, err := strconv.Atoi(prefix) + if err != nil || n <= 0 { + return 1, s + } + return n, strings.TrimSpace(s[i+1:]) +} + +// ---- Formatting ---- + +type FormatOptions struct { + BoldMinMax bool +} + +func DefaultFormatOptions() FormatOptions { + return FormatOptions{BoldMinMax: true} +} + +func FormatResult(r MultiResult, opts FormatOptions) string { + lines := make([]string, 0, len(r.Items)) + for _, item := range r.Items { + lines = append(lines, formatOne(item, opts)) + } + return strings.Join(lines, "\n") +} + +func formatOne(item EvalResult, opts FormatOptions) string { + // If there were multiple roll groups, we still show them in sequence, e.g. "[...][...] expr". + parts := make([]string, 0, len(item.Trace.Rolls)) + for _, g := range item.Trace.Rolls { + parts = append(parts, formatRollGroup(g, opts)) + } + breakdown := strings.Join(parts, " ") + if breakdown == "" { + breakdown = "[]" + } + return fmt.Sprintf("%d \u2190 %s %s", item.Total, breakdown, item.Trace.Text) +} + +func formatRollGroup(g RollGroup, opts FormatOptions) string { + items := make([]string, 0, len(g.Dice)) + for _, d := range g.Dice { + items = append(items, formatDie(d, g.Sides, opts)) + } + return "[" + strings.Join(items, ", ") + "]" +} + +func formatDie(d Die, sides int, opts FormatOptions) string { + // Chain faces with '+' if exploded. + faceParts := make([]string, 0, len(d.Faces)) + for _, v := range d.Faces { + faceParts = append(faceParts, formatFace(v, sides, opts)) + } + txt := strings.Join(faceParts, "+") + if len(d.Faces) > 1 { + // denote explosion occurred (kept compact) + txt = txt + "!" + } + if !d.Kept { + return "~~" + txt + "~~" + } + return txt +} + +func formatFace(v, sides int, opts FormatOptions) string { + if !opts.BoldMinMax { + return strconv.Itoa(v) + } + if v == 1 || v == sides { + return "**" + strconv.Itoa(v) + "**" + } + return strconv.Itoa(v) +} + +// ---- AST / Parser / Evaluator ---- + +type nodeKind int + +const ( + kindNumber nodeKind = iota + kindUnary + kindBinary + kindDice +) + +type node struct { + kind nodeKind + + // number + num int + + // unary + unOp byte + unA *node + + // binary + binOp byte + binL *node + binR *node + + // dice + diceCount *node + diceSides *node + explode bool + keepDrop string // kh/kl/dh/dl + keepN *node +} + +type parser struct { + s string + i int + limits Limits + depth int +} + +func newParser(s string, limits Limits) *parser { + return &parser{s: strings.TrimSpace(s), limits: limits} +} + +func (p *parser) parse() (*node, error) { + p.skipSpaces() + n, err := p.parseExpr() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.i != len(p.s) { + return nil, errors.New("trailing input") + } + return n, nil +} + +func (p *parser) parseExpr() (*node, error) { + // expr = term {( + | - ) term} + left, err := p.parseTerm() + if err != nil { + return nil, err + } + for { + p.skipSpaces() + if p.peek() != '+' && p.peek() != '-' { + return left, nil + } + op := p.next() + right, err := p.parseTerm() + if err != nil { + return nil, err + } + left = &node{kind: kindBinary, binOp: op, binL: left, binR: right} + } +} + +func (p *parser) parseTerm() (*node, error) { + // term = factor {( * | / ) factor} + left, err := p.parseFactor() + if err != nil { + return nil, err + } + for { + p.skipSpaces() + if p.peek() != '*' && p.peek() != '/' { + return left, nil + } + op := p.next() + right, err := p.parseFactor() + if err != nil { + return nil, err + } + left = &node{kind: kindBinary, binOp: op, binL: left, binR: right} + } +} + +func (p *parser) parseFactor() (*node, error) { + // factor = unary + return p.parseUnary() +} + +func (p *parser) parseUnary() (*node, error) { + p.skipSpaces() + if p.peek() == '+' || p.peek() == '-' { + op := p.next() + a, err := p.parseUnary() + if err != nil { + return nil, err + } + return &node{kind: kindUnary, unOp: op, unA: a}, nil + } + return p.parsePrimaryOrDice() +} + +func (p *parser) parsePrimaryOrDice() (*node, error) { + p.skipSpaces() + if p.peek() == '(' { + p.next() + p.depth++ + if p.limits.MaxAstDepth > 0 && p.depth > p.limits.MaxAstDepth { + return nil, errors.New("expression too deep") + } + n, err := p.parseExpr() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.peek() != ')' { + return nil, errors.New("missing )") + } + p.next() + p.depth-- + return n, nil + } + + // Try parse leading number or dice with omitted count. + start := p.i + num, hasNum, err := p.tryParseInt() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.peekLower() == 'd' { + // dice: [count] d sides [keep/drop] [explode] + var countNode *node + if hasNum { + countNode = &node{kind: kindNumber, num: num} + } else { + countNode = &node{kind: kindNumber, num: 1} + } + p.next() // d + sidesNode, err := p.parseSides() + if err != nil { + return nil, err + } + keepDrop, keepN, err := p.parseKeepDrop() + if err != nil { + return nil, err + } + explode := false + p.skipSpaces() + if p.peek() == '!' { + explode = true + p.next() + } + return &node{ + kind: kindDice, + diceCount: countNode, + diceSides: sidesNode, + explode: explode, + keepDrop: keepDrop, + keepN: keepN, + }, nil + } + + // Not a dice; if we had a number, return number; else error. + if hasNum { + return &node{kind: kindNumber, num: num}, nil + } + p.i = start + return nil, errors.New("expected number, dice, or (") +} + +func (p *parser) parseSides() (*node, error) { + p.skipSpaces() + if p.peek() == '%' { + p.next() + return &node{kind: kindNumber, num: 100}, nil + } + n, ok, err := p.tryParseInt() + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("expected sides") + } + return &node{kind: kindNumber, num: n}, nil +} + +func (p *parser) parseKeepDrop() (string, *node, error) { + p.skipSpaces() + // khN klN dhN dlN + if p.peekLower() != 'k' && p.peekLower() != 'd' { + return "", nil, nil + } + c1 := p.peekLower() + if c1 != 'k' && c1 != 'd' { + return "", nil, nil + } + if p.i+1 >= len(p.s) { + return "", nil, nil + } + c2 := byte(unicode.ToLower(rune(p.s[p.i+1]))) + if c2 != 'h' && c2 != 'l' { + return "", nil, nil + } + op := string([]byte{c1, c2}) + p.i += 2 + n, ok, err := p.tryParseInt() + if err != nil { + return "", nil, err + } + if !ok || n <= 0 { + return "", nil, errors.New("expected keep/drop count") + } + return op, &node{kind: kindNumber, num: n}, nil +} + +func (p *parser) tryParseInt() (int, bool, error) { + p.skipSpaces() + if p.i >= len(p.s) || !unicode.IsDigit(rune(p.s[p.i])) { + return 0, false, nil + } + start := p.i + for p.i < len(p.s) && unicode.IsDigit(rune(p.s[p.i])) { + p.i++ + } + v, err := strconv.Atoi(p.s[start:p.i]) + if err != nil { + return 0, false, err + } + return v, true, nil +} + +func (p *parser) skipSpaces() { + for p.i < len(p.s) && unicode.IsSpace(rune(p.s[p.i])) { + p.i++ + } +} + +func (p *parser) peek() byte { + if p.i >= len(p.s) { + return 0 + } + return p.s[p.i] +} + +func (p *parser) peekLower() byte { + if p.i >= len(p.s) { + return 0 + } + return byte(unicode.ToLower(rune(p.s[p.i]))) +} + +func (p *parser) next() byte { + if p.i >= len(p.s) { + return 0 + } + b := p.s[p.i] + p.i++ + return b +} + +func normalizeSpaces(s string) string { + // Keep compact: remove spaces entirely, but preserve leading repeat already stripped outside. + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if !unicode.IsSpace(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// formatExpr renders a canonical expression with explicit dice counts and +// spaces around binary operators. +func formatExpr(n *node) string { + return formatExprPrec(n, 0) +} + +func formatExprPrec(n *node, parentPrec int) string { + switch n.kind { + case kindNumber: + return strconv.Itoa(n.num) + case kindUnary: + inner := formatExprPrec(n.unA, 3) + if n.unOp == '+' { + return inner + } + return string(n.unOp) + inner + case kindDice: + // count and sides are currently numbers, but we keep it generic. + count := formatExprPrec(n.diceCount, 4) + sides := formatExprPrec(n.diceSides, 4) + out := count + "d" + sides + if n.keepDrop != "" && n.keepN != nil { + out += n.keepDrop + formatExprPrec(n.keepN, 4) + } + if n.explode { + out += "!" + } + return out + case kindBinary: + prec := binPrec(n.binOp) + l := formatExprPrec(n.binL, prec) + r := formatExprPrec(n.binR, prec+1) + out := l + " " + string(n.binOp) + " " + r + if prec < parentPrec { + return "(" + out + ")" + } + return out + default: + return "" + } +} + +func binPrec(op byte) int { + switch op { + case '+', '-': + return 1 + case '*', '/': + return 2 + default: + return 0 + } +} + +type evaluator struct { + limits Limits + dice int +} + +func (e *evaluator) eval(n *node) (int, *Trace, error) { + t := &Trace{} + v, err := e.evalNode(n, t) + if err != nil { + return 0, nil, err + } + return v, t, nil +} + +func (e *evaluator) evalNode(n *node, t *Trace) (int, error) { + switch n.kind { + case kindNumber: + return n.num, nil + case kindUnary: + v, err := e.evalNode(n.unA, t) + if err != nil { + return 0, err + } + switch n.unOp { + case '+': + return v, nil + case '-': + return -v, nil + default: + return 0, errors.New("bad unary op") + } + case kindBinary: + l, err := e.evalNode(n.binL, t) + if err != nil { + return 0, err + } + r, err := e.evalNode(n.binR, t) + if err != nil { + return 0, err + } + switch n.binOp { + case '+': + return l + r, nil + case '-': + return l - r, nil + case '*': + return l * r, nil + case '/': + if r == 0 { + return 0, errors.New("division by zero") + } + return l / r, nil + default: + return 0, errors.New("bad binary op") + } + case kindDice: + return e.evalDice(n, t) + default: + return 0, errors.New("unknown node") + } +} + +func (e *evaluator) evalDice(n *node, t *Trace) (int, error) { + count, err := e.evalNode(n.diceCount, t) + if err != nil { + return 0, err + } + sides, err := e.evalNode(n.diceSides, t) + if err != nil { + return 0, err + } + if count <= 0 || sides <= 0 { + return 0, errors.New("invalid dice") + } + if e.limits.MaxDice > 0 && e.dice+count > e.limits.MaxDice { + return 0, errors.New("too many dice") + } + if e.limits.MaxSides > 0 && sides > e.limits.MaxSides { + return 0, errors.New("sides too large") + } + e.dice += count + + keepDrop := n.keepDrop + keepN := 0 + if n.keepN != nil { + keepN, err = e.evalNode(n.keepN, t) + if err != nil { + return 0, err + } + } + if keepDrop != "" && keepN <= 0 { + return 0, errors.New("invalid keep/drop") + } + if keepDrop != "" && keepN > count { + keepN = count + } + + g := RollGroup{ + Count: count, + Sides: sides, + Dice: make([]Die, 0, count), + KeepDrop: keepDrop, + KeepDropN: keepN, + } + + type dieScore struct { + idx int + score int + } + scores := make([]dieScore, 0, count) + + // Roll base dice. + for i := 0; i < count; i++ { + faces, err := rollExploding(sides, n.explode, e.limits.MaxExplosionDepth) + if err != nil { + return 0, err + } + sum := 0 + for _, v := range faces { + sum += v + } + g.Dice = append(g.Dice, Die{Faces: faces, Kept: true}) + scores = append(scores, dieScore{idx: i, score: sum}) + } + + // Apply keep/drop to base dice by comparing chain sums. + if keepDrop != "" { + sort.SliceStable(scores, func(i, j int) bool { return scores[i].score < scores[j].score }) + + kept := make(map[int]bool, count) + switch keepDrop { + case "kh": + for i := len(scores) - keepN; i < len(scores); i++ { + if i >= 0 && i < len(scores) { + kept[scores[i].idx] = true + } + } + case "kl": + for i := 0; i < keepN && i < len(scores); i++ { + kept[scores[i].idx] = true + } + case "dh": + // drop highest N => keep all except highest N + for i := 0; i < len(scores)-keepN; i++ { + if i >= 0 && i < len(scores) { + kept[scores[i].idx] = true + } + } + case "dl": + // drop lowest N => keep highest count-N + for i := keepN; i < len(scores); i++ { + kept[scores[i].idx] = true + } + default: + return 0, errors.New("unknown keep/drop") + } + for i := range g.Dice { + g.Dice[i].Kept = kept[i] + } + } + + // Sum kept dice. + total := 0 + for _, d := range g.Dice { + if !d.Kept { + continue + } + for _, v := range d.Faces { + total += v + } + } + + t.Rolls = append(t.Rolls, g) + return total, nil +} + +func rollExploding(sides int, explode bool, maxDepth int) ([]int, error) { + v, err := roll1(sides) + if err != nil { + return nil, err + } + out := []int{v} + if !explode { + return out, nil + } + depth := 0 + for v == sides { + depth++ + if maxDepth > 0 && depth > maxDepth { + return nil, errors.New("explosion depth limit") + } + v, err = roll1(sides) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + +func roll1(sides int) (int, error) { + if sides <= 0 { + return 0, errors.New("bad sides") + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(sides))) + if err != nil { + return 0, err + } + return int(n.Int64()) + 1, nil +} + diff --git a/internal/events/meeting_voice.go b/internal/events/meeting_voice.go new file mode 100644 index 0000000..d63314a --- /dev/null +++ b/internal/events/meeting_voice.go @@ -0,0 +1,116 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleVoiceStateUpdate implements the "meeting lobby" behavior: +// - When a user joins the configured lobby voice channel, create a temporary voice channel +// with the same UserLimit and move the user into it. +// - When a temporary channel becomes empty, delete it. +func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { + if svc == nil || svc.Meeting == nil { + return + } + if vs == nil || vs.VoiceState == nil { + return + } + if vs.GuildID == "" || vs.UserID == "" { + return + } + + ctx := context.Background() + guildID64, _ := strconv.ParseInt(vs.GuildID, 10, 64) + + // Clean up the channel the user left (if any) + if vs.BeforeUpdate != nil && vs.BeforeUpdate.ChannelID != "" { + tryCleanupTempChannel(s, ctx, svc, vs.GuildID, guildID64, vs.BeforeUpdate.ChannelID) + } + + // If not joining a channel (disconnect), nothing else to do. + if vs.ChannelID == "" { + return + } + + lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) + if err != nil || !ok || lobbyID == 0 { + return + } + if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { + return + } + + lobbyCh, err := s.Channel(vs.ChannelID) + if err != nil || lobbyCh == nil { + return + } + + // Create the new channel in the same category as the lobby, with same user limit. + newName := fmt.Sprintf("%s • %s", lobbyCh.Name, time.Now().Format("15:04")) + newCh, err := s.GuildChannelCreateComplex(vs.GuildID, discordgo.GuildChannelCreateData{ + Name: newName, + Type: discordgo.ChannelTypeGuildVoice, + ParentID: lobbyCh.ParentID, + UserLimit: lobbyCh.UserLimit, + Bitrate: lobbyCh.Bitrate, + }) + if err != nil || newCh == nil { + log.Printf("meeting: failed creating temp channel guild=%s user=%s err=%v", vs.GuildID, vs.UserID, err) + return + } + + newChID64, _ := strconv.ParseInt(newCh.ID, 10, 64) + _ = svc.Meeting.AddTempChannel(ctx, guildID64, newChID64, mustParseInt64(vs.UserID)) + + // Move the user into the new channel. + targetID := newCh.ID + if err := s.GuildMemberMove(vs.GuildID, vs.UserID, &targetID); err != nil { + log.Printf("meeting: failed moving member guild=%s user=%s channel=%s err=%v", vs.GuildID, vs.UserID, newCh.ID, err) + // If move fails, delete the channel to avoid junk. + _, _ = s.ChannelDelete(newCh.ID) + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, newChID64) + return + } +} + +func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *services.Services, guildID string, guildID64 int64, channelID string) { + chID64, err := strconv.ParseInt(channelID, 10, 64) + if err != nil || chID64 == 0 { + return + } + isTemp, err := svc.Meeting.IsTempChannel(ctx, guildID64, chID64) + if err != nil || !isTemp { + return + } + + // Check if anyone is still in the channel. + // IMPORTANT: don't use REST `s.Guild(...)` here; it doesn't include VoiceStates. + // Use the session state cache (requires IntentsGuildVoiceStates). + if s == nil || s.State == nil { + return + } + g, err := s.State.Guild(guildID) + if err != nil || g == nil { + return + } + for _, st := range g.VoiceStates { + if st != nil && st.ChannelID == channelID { + return + } + } + + if _, err := s.ChannelDelete(channelID); err != nil { + // If already deleted, still remove from DB. + log.Printf("meeting: failed deleting empty temp channel guild=%s channel=%s err=%v", guildID, channelID, err) + } + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) +} + diff --git a/main.go b/main.go index 50090cd..8073d8a 100644 --- a/main.go +++ b/main.go @@ -11,10 +11,13 @@ import ( "velox-bot/internal/config" "velox-bot/internal/db" "velox-bot/internal/db/repos/levelrepo" + "velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/settingsrepo" "velox-bot/internal/db/services" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/rps" ) func main() { @@ -33,9 +36,12 @@ func main() { levelRepo := levelrepo.NewRepo(db) settingsRepo := settingsrepo.NewRepo(db) + rpsRepo := rpsrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) - services := services.NewServices(levelService, levelSettingsService) + meetingService := meeting.New(settingsRepo) + rpsService := rps.New(rpsRepo) + services := services.NewServices(levelService, levelSettingsService, meetingService, rpsService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) if err != nil { diff --git a/schema.sql b/schema.sql index d7e5ff7..e647669 100644 --- a/schema.sql +++ b/schema.sql @@ -68,4 +68,18 @@ CREATE TABLE IF NOT EXISTS logsettings ( is_enabled BOOLEAN NOT NULL DEFAULT FALSE ); +-- Meeting rooms (voice lobby -> auto-created temporary voice channels) +CREATE TABLE IF NOT EXISTS meeting_settings ( + guild_id BIGINT PRIMARY KEY, + lobby_channel_id BIGINT +); + +CREATE TABLE IF NOT EXISTS meeting_temp_channels ( + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, channel_id) +); + -- \ No newline at end of file