Merge pull request 'ft/meetings' (#2) from ft/meetings into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -24,6 +24,12 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
|
|||||||
return nil, err
|
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{
|
return &Bot{
|
||||||
Session: session,
|
Session: session,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
@@ -54,6 +60,10 @@ func (b *Bot) Start() error {
|
|||||||
events.HandleMessageCreate(s, m, b.Services)
|
events.HandleMessageCreate(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
|
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,26 +4,41 @@ import (
|
|||||||
"velox-bot/internal/commands/fun"
|
"velox-bot/internal/commands/fun"
|
||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
cmdlevel "velox-bot/internal/commands/level"
|
cmdlevel "velox-bot/internal/commands/level"
|
||||||
|
"velox-bot/internal/commands/meeting"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllCommands = []*discordgo.ApplicationCommand{
|
var AllCommands = []*discordgo.ApplicationCommand{
|
||||||
fun.Ping,
|
fun.Ping,
|
||||||
|
fun.Joke,
|
||||||
|
fun.Coinflip,
|
||||||
|
fun.Dice,
|
||||||
|
fun.Rps,
|
||||||
|
fun.RpsStats,
|
||||||
|
fun.RpsLeaderboard,
|
||||||
help.Help,
|
help.Help,
|
||||||
cmdlevel.Slvl,
|
cmdlevel.Slvl,
|
||||||
cmdlevel.Rank,
|
cmdlevel.Rank,
|
||||||
cmdlevel.Leaderboard,
|
cmdlevel.Leaderboard,
|
||||||
cmdlevel.Rewards,
|
cmdlevel.Rewards,
|
||||||
|
meeting.Meeting,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
"ping": fun.PingHandler,
|
"ping": fun.PingHandler,
|
||||||
"help": help.HelpHandler,
|
"joke": fun.JokeHandler,
|
||||||
|
"coinflip": fun.CoinflipHandler,
|
||||||
|
"dice": fun.DiceHandler,
|
||||||
|
"rps": fun.RpsHandler,
|
||||||
|
"rpsstats": fun.RpsStatsHandler,
|
||||||
|
"rpsleaderboard": fun.RpsLeaderboardHandler,
|
||||||
|
"help": help.HelpHandler,
|
||||||
"slvl": cmdlevel.SlvlHandler,
|
"slvl": cmdlevel.SlvlHandler,
|
||||||
"rank": cmdlevel.RankHandler,
|
"rank": cmdlevel.RankHandler,
|
||||||
"leaderboard": cmdlevel.LeaderboardHandler,
|
"leaderboard": cmdlevel.LeaderboardHandler,
|
||||||
"rewards": cmdlevel.RewardsHandler,
|
"rewards": cmdlevel.RewardsHandler,
|
||||||
|
"meeting": meeting.MeetingHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
|||||||
@@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package fun
|
package public
|
||||||
|
|
||||||
import "github.com/bwmarrin/discordgo"
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -47,12 +47,36 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
Fields: []*discordgo.MessageEmbedField{
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
{
|
{
|
||||||
Name: "Fun Commands",
|
Name: "Fun Commands",
|
||||||
Value: "Here are the fun commands for the bot.",
|
Value: "Small utility + games.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "/ping",
|
Name: "/ping",
|
||||||
Value: "Pong!",
|
Value: "Pong!",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "/joke [type]",
|
||||||
|
Value: "Get a random joke (optional category).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/coinflip",
|
||||||
|
Value: "Flip a coin.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/dice <expr>",
|
||||||
|
Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/rps <hand>",
|
||||||
|
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",
|
Name: "/help",
|
||||||
Value: "Get help with the bot.",
|
Value: "Get help with the bot.",
|
||||||
@@ -69,6 +93,30 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
Name: "/slvl set",
|
Name: "/slvl set",
|
||||||
Value: "Set the level of a user.",
|
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,
|
Author: &author,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(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 LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RewardsHandler(s, i) }
|
public.LeaderboardHandler(s, i)
|
||||||
func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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) }
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,96 @@ func NewRepo(db *sql.DB) *Repo {
|
|||||||
return &Repo{db: db}
|
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 {
|
func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO levelsettings (guild_id, levelsys)
|
INSERT INTO levelsettings (guild_id, levelsys)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -3,19 +3,25 @@ package services
|
|||||||
import (
|
import (
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
|
"velox-bot/internal/db/services/meeting"
|
||||||
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
Level *level.Service
|
Level *level.Service
|
||||||
LevelSettings *levelsettings.Service
|
LevelSettings *levelsettings.Service
|
||||||
|
Meeting *meeting.Service
|
||||||
|
RPS *rps.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Services
|
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{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
|
Meeting: meeting,
|
||||||
|
RPS: rps,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,10 +11,13 @@ import (
|
|||||||
"velox-bot/internal/config"
|
"velox-bot/internal/config"
|
||||||
"velox-bot/internal/db"
|
"velox-bot/internal/db"
|
||||||
"velox-bot/internal/db/repos/levelrepo"
|
"velox-bot/internal/db/repos/levelrepo"
|
||||||
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
|
"velox-bot/internal/db/services/meeting"
|
||||||
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -33,9 +36,12 @@ func main() {
|
|||||||
|
|
||||||
levelRepo := levelrepo.NewRepo(db)
|
levelRepo := levelrepo.NewRepo(db)
|
||||||
settingsRepo := settingsrepo.NewRepo(db)
|
settingsRepo := settingsrepo.NewRepo(db)
|
||||||
|
rpsRepo := rpsrepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(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)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+14
@@ -68,4 +68,18 @@ CREATE TABLE IF NOT EXISTS logsettings (
|
|||||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
--
|
--
|
||||||
Reference in New Issue
Block a user