Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76aeca3de6 | ||
|
|
c4dd7302bc | ||
|
|
0b36bc9e93 | ||
|
|
dee5008c60 | ||
|
|
a664ae15fd | ||
|
|
825dca34f0 | ||
|
|
d399122cab | ||
|
|
0f49c79567 | ||
|
|
8ba26f8bb9 | ||
|
|
2bce787c05 |
@@ -0,0 +1,58 @@
|
||||
#:schema https://json.schemastore.org/any.json
|
||||
|
||||
env_files = [".env"]
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/bot"
|
||||
cmd = "go build -o ./tmp/bot ."
|
||||
delay = 1000
|
||||
entrypoint = ["./tmp/bot"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
ignore_dangerous_root_dir = false
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "2s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = true
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
app_start_timeout = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
.cursor
|
||||
.cursor
|
||||
tmp/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
start: up
|
||||
air
|
||||
@@ -0,0 +1,23 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: velox-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: velox
|
||||
POSTGRES_PASSWORD: velox_pwd
|
||||
POSTGRES_DB: velox
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- velox_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U velox -d velox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
velox_pgdata:
|
||||
@@ -4,11 +4,20 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/image v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+20
-2
@@ -3,6 +3,8 @@ package bot
|
||||
import (
|
||||
"log"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -13,19 +15,27 @@ type Bot struct {
|
||||
GuildID string
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand) (*Bot, error) {
|
||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
|
||||
session, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
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,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -45,8 +55,16 @@ func (b *Bot) Start() error {
|
||||
}
|
||||
|
||||
b.Session.AddHandler(commands.HandleInteraction)
|
||||
return nil
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
events.HandleMessageCreate(s, m, b.Services)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Close() error {
|
||||
|
||||
@@ -2,16 +2,43 @@ package commands
|
||||
|
||||
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.PingCommand,
|
||||
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.HandlePing,
|
||||
"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) {
|
||||
|
||||
@@ -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,13 +1,13 @@
|
||||
package fun
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
var PingCommand = &discordgo.ApplicationCommand{
|
||||
var Ping = &discordgo.ApplicationCommand{
|
||||
Name: "ping",
|
||||
Description: "Ping the bot",
|
||||
}
|
||||
|
||||
func HandlePing(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Help = &discordgo.ApplicationCommand{
|
||||
Name: "help",
|
||||
Description: "Get help with the bot",
|
||||
}
|
||||
|
||||
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Check your DMs for help.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
|
||||
userID := ""
|
||||
|
||||
// check if command is run in a guild
|
||||
if i.Member != nil {
|
||||
userID = i.Member.User.ID
|
||||
} else {
|
||||
userID = i.Interaction.User.ID
|
||||
}
|
||||
|
||||
dmChannel, err := s.UserChannelCreate(userID)
|
||||
if err != nil {
|
||||
log.Printf("Error creating DM channel: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
author := discordgo.MessageEmbedAuthor{
|
||||
Name: "Velox Bot",
|
||||
URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot",
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Velox Bot Help",
|
||||
Description: "Here is the help for the bot.",
|
||||
Color: 0xFFA500,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Fun Commands",
|
||||
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 <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",
|
||||
Value: "Get help with the bot.",
|
||||
},
|
||||
{
|
||||
Name: "Leveling System",
|
||||
Value: `The leveling system is a system that allows users to gain XP and level up. The system is based on the amount of messages sent in a server. The more messages you send, the more XP you gain. The more XP you gain, the higher your level will be. The leveling system is disabled by default, however you can enable/disable it by using the /slvl toggle command. You can also configure the leveling system by using the /slvl command.`,
|
||||
},
|
||||
{
|
||||
Name: "/slvl toggle",
|
||||
Value: "Toggle the leveling system.",
|
||||
},
|
||||
{
|
||||
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,
|
||||
}
|
||||
|
||||
s.ChannelMessageSendEmbed(dmChannel.ID, embed)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Slvl = &discordgo.ApplicationCommand{
|
||||
Name: "slvl",
|
||||
Description: "Leveling System",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
slvlToggleOption(),
|
||||
slvlSetLevelOption(),
|
||||
slvlSetChannelOption(),
|
||||
slvlSetMessageOption(),
|
||||
slvlSetRewardOption(),
|
||||
},
|
||||
}
|
||||
|
||||
func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
switch data.Options[0].Name {
|
||||
case "toggle":
|
||||
slvlToggleHandler(s, i)
|
||||
case "set":
|
||||
slvlSetLevelHandler(s, i)
|
||||
case "set-channel":
|
||||
slvlSetChannelHandler(s, i)
|
||||
case "set-message":
|
||||
slvlSetMessageHandler(s, i)
|
||||
case "set-reward":
|
||||
slvlSetRewardHandler(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Invalid subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetChannelOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-channel",
|
||||
Description: "Set the level up channel",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "Channel for level-up messages",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetChannelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
shared.RespondEphemeral(s, i, "Invalid 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.LevelSettings.SetLevelUpChannel(context.Background(), guildID, channelID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level up channel.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level up channel set to "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetLevelOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set",
|
||||
Description: "Set the level of a user",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "The user to set the level of",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "level",
|
||||
Description: "The level to set the user to",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetLevelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.Level == nil {
|
||||
shared.RespondEphemeral(s, i, "Leveling service is not available.")
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
userOpt, ok := optMap["user"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing user option.")
|
||||
return
|
||||
}
|
||||
levelOpt, ok := optMap["level"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing level option.")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser := userOpt.UserValue(s)
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.Bot {
|
||||
shared.RespondEphemeral(s, i, "You can't set a bot's level.")
|
||||
return
|
||||
}
|
||||
|
||||
newLevel := int(levelOpt.IntValue())
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Level.SetLevel(context.Background(), guildID, userID, newLevel); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level set to "+strconv.Itoa(newLevel)+" for "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetMessageOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-message",
|
||||
Description: "Set the level up message",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "message",
|
||||
Description: "Level-up message. Variables: {user}, {level}",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetMessageHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
messageOpt, ok := optMap["message"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing message option.")
|
||||
return
|
||||
}
|
||||
|
||||
message := messageOpt.StringValue()
|
||||
if message == "" {
|
||||
message = "GG {user}, you reached level {level}!"
|
||||
}
|
||||
|
||||
if err := services.Global.LevelSettings.SetLevelUpMessage(context.Background(), guildID, message); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level up message.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level up message set.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetRewardOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-reward",
|
||||
Description: "Set role reward for a level",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "level",
|
||||
Description: "Level to reward",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionRole,
|
||||
Name: "role",
|
||||
Description: "Role to grant",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
levelOpt, ok := optMap["level"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing level option.")
|
||||
return
|
||||
}
|
||||
roleOpt, ok := optMap["role"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing role option.")
|
||||
return
|
||||
}
|
||||
|
||||
level := int(levelOpt.IntValue())
|
||||
role := roleOpt.RoleValue(s, i.GuildID)
|
||||
if role == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid role.")
|
||||
return
|
||||
}
|
||||
roleID, err := strconv.ParseInt(role.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid role ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.LevelSettings.SetRoleRewardForLevel(context.Background(), guildID, level, roleID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set reward.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Reward set: level "+strconv.Itoa(level)+" -> "+role.Mention())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlToggleOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "toggle",
|
||||
Description: "Toggle the leveling system",
|
||||
}
|
||||
}
|
||||
|
||||
func slvlToggleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
enabled, err := services.Global.LevelSettings.ToggleLevelSystem(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to toggle leveling system.")
|
||||
return
|
||||
}
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
shared.RespondEphemeral(s, i, "Leveling system "+status+" for this server.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Leaderboard = &discordgo.ApplicationCommand{
|
||||
Name: "leaderboard",
|
||||
Description: "Top 10 levels in this server",
|
||||
}
|
||||
|
||||
func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelServices(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
top, err := services.Global.Level.TopLevels(ctx, 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("Level: **%d** | XP: **%d**", entry.Level, entry.XP),
|
||||
Inline: false,
|
||||
})
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Leaderboard",
|
||||
Description: fmt.Sprintf("These are the top %d users in the server", len(top)),
|
||||
Fields: fields,
|
||||
Color: 0xF59E0B,
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/rankcard"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Rank = &discordgo.ApplicationCommand{
|
||||
Name: "rank",
|
||||
Description: "Get your level card",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "The user to get the rank card of",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireLevelServices(s, i) {
|
||||
return
|
||||
}
|
||||
if i.GuildID != "" && !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target user: option or caller.
|
||||
target := i.User
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
target = i.Member.User
|
||||
}
|
||||
if len(i.ApplicationCommandData().Options) > 0 {
|
||||
if u := i.ApplicationCommandData().Options[0].UserValue(s); u != nil {
|
||||
target = u
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(target.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
lvl, err := services.Global.Level.GetLevel(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load level.")
|
||||
return
|
||||
}
|
||||
|
||||
percent := float64(lvl.XP) / 100.0
|
||||
avatarURL := target.AvatarURL("256")
|
||||
|
||||
pngBytes, err := rankcard.RenderPNG(ctx, rankcard.Data{
|
||||
Name: target.Username,
|
||||
Level: lvl.Level,
|
||||
XP: lvl.XP,
|
||||
Percent: percent,
|
||||
Avatar: avatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to render rank card.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Files: []*discordgo.File{
|
||||
{
|
||||
Name: "levelcard.png",
|
||||
Reader: bytes.NewReader(pngBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Rewards = &discordgo.ApplicationCommand{
|
||||
Name: "rewards",
|
||||
Description: "View level rewards in this server",
|
||||
}
|
||||
|
||||
func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rewards, err := services.Global.LevelSettings.ListRoleRewards(ctx, guildID, 25)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load rewards.")
|
||||
return
|
||||
}
|
||||
if len(rewards) == 0 {
|
||||
shared.RespondEphemeral(s, i, "No rewards configured yet.")
|
||||
return
|
||||
}
|
||||
|
||||
fields := make([]*discordgo.MessageEmbedField, 0, len(rewards))
|
||||
for _, r := range rewards {
|
||||
fields = append(fields, &discordgo.MessageEmbedField{
|
||||
Name: fmt.Sprintf("Level %d", r.Level),
|
||||
Value: fmt.Sprintf("<@&%d>", r.RoleID),
|
||||
Inline: true,
|
||||
})
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Level Rewards",
|
||||
Description: "Roles granted when you reach a level.",
|
||||
Fields: fields,
|
||||
Color: 0xF59E0B,
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/level/config"
|
||||
"velox-bot/internal/commands/level/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Commands
|
||||
var (
|
||||
Rank *discordgo.ApplicationCommand = public.Rank
|
||||
Leaderboard *discordgo.ApplicationCommand = public.Leaderboard
|
||||
Rewards *discordgo.ApplicationCommand = public.Rewards
|
||||
Slvl *discordgo.ApplicationCommand = config.Slvl
|
||||
)
|
||||
|
||||
// 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) }
|
||||
@@ -0,0 +1,95 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 leveling settings.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireLevelServices(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Level == nil || services.Global.LevelSettings == nil {
|
||||
RespondEphemeral(s, i, "Leveling service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireLevelSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.LevelSettings == nil {
|
||||
RespondEphemeral(s, i, "Leveling 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 RequireLevelSystemEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if !RequireLevelSettingsService(s, i) {
|
||||
return false
|
||||
}
|
||||
guildID, ok := ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
enabled, err := services.Global.LevelSettings.IsLevelSystemEnabled(context.Background(), guildID)
|
||||
if err != nil {
|
||||
RespondEphemeral(s, i, "Failed to load leveling settings.")
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
RespondEphemeral(s, i, "Leveling system is disabled on this server. Use /slvl toggle to enable it.")
|
||||
return false
|
||||
}
|
||||
return 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,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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Config struct {
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
@@ -33,9 +34,15 @@ func LoadConfig() (*Config, error) {
|
||||
return nil, fmt.Errorf("GUILD_ID is not set")
|
||||
}
|
||||
|
||||
dbHost := os.Getenv("DB_HOST")
|
||||
if dbHost == "" {
|
||||
return nil, fmt.Errorf("DB_HOST is not set")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
DBHost: dbHost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
"velox-bot/internal/config"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func NewDB(config *config.Config) (*sql.DB, error) {
|
||||
pool, err := sql.Open("pgx", config.DBHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pool.SetMaxIdleConns(10)
|
||||
pool.SetMaxOpenConns(100)
|
||||
pool.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := pool.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package levelrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Level struct {
|
||||
GuildID int64
|
||||
UserID int64
|
||||
Level int
|
||||
XP int64
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) GetLevel(ctx context.Context, guildID, userID int64) (*Level, error) {
|
||||
const q = `
|
||||
SELECT level, xp
|
||||
FROM levels
|
||||
WHERE guild = $1 AND user_id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, userID)
|
||||
var lvl Level
|
||||
lvl.GuildID = guildID
|
||||
lvl.UserID = userID
|
||||
switch err := row.Scan(&lvl.Level, &lvl.XP); err {
|
||||
case sql.ErrNoRows:
|
||||
// default new user
|
||||
lvl.Level = 0
|
||||
lvl.XP = 0
|
||||
return &lvl, nil
|
||||
case nil:
|
||||
return &lvl, nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
func (r *Repo) UpsertLevel(ctx context.Context, lvl *Level) error {
|
||||
const q = `
|
||||
INSERT INTO levels (guild, user_id, level, xp)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild, user_id)
|
||||
DO UPDATE SET level = EXCLUDED.level, xp = EXCLUDED.xp
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, lvl.GuildID, lvl.UserID, lvl.Level, lvl.XP)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
|
||||
const q = `
|
||||
INSERT INTO levels (guild, user_id, level, xp)
|
||||
VALUES ($1, $2, $3, 0)
|
||||
ON CONFLICT (guild, user_id)
|
||||
DO UPDATE SET level = EXCLUDED.level, xp = 0
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, userID, level)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) TopLevels(ctx context.Context, guildID int64, limit int) ([]*Level, error) {
|
||||
const q = `
|
||||
SELECT user_id, level, xp
|
||||
FROM levels
|
||||
WHERE guild = $1
|
||||
ORDER BY level DESC, xp DESC, user_id ASC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Level, 0, limit)
|
||||
for rows.Next() {
|
||||
lvl := &Level{GuildID: guildID}
|
||||
if err := rows.Scan(&lvl.UserID, &lvl.Level, &lvl.XP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, lvl)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package settingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type LevelReward struct {
|
||||
GuildID int64
|
||||
Level int
|
||||
RoleID int64
|
||||
}
|
||||
|
||||
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)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET levelsys = EXCLUDED.levelsys
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
const q = `
|
||||
SELECT levelsys
|
||||
FROM levelsettings
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var enabled bool
|
||||
switch err := row.Scan(&enabled); err {
|
||||
case nil:
|
||||
return enabled, nil
|
||||
case sql.ErrNoRows:
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
|
||||
const q = `
|
||||
SELECT levelup_channel_id
|
||||
FROM levelup
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var channelID sql.NullInt64
|
||||
switch err := row.Scan(&channelID); err {
|
||||
case nil:
|
||||
if !channelID.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return channelID.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error) {
|
||||
const q = `
|
||||
SELECT role
|
||||
FROM levelrewards
|
||||
WHERE guild_id = $1 AND levelreq = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, level)
|
||||
var role sql.NullInt64
|
||||
switch err := row.Scan(&role); err {
|
||||
case nil:
|
||||
if !role.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return role.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
|
||||
const q = `
|
||||
INSERT INTO levelrewards (guild_id, levelreq, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (guild_id, levelreq)
|
||||
DO UPDATE SET role = EXCLUDED.role
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, level, roleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*LevelReward, error) {
|
||||
const q = `
|
||||
SELECT levelreq, role
|
||||
FROM levelrewards
|
||||
WHERE guild_id = $1
|
||||
ORDER BY levelreq ASC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*LevelReward, 0, limit)
|
||||
for rows.Next() {
|
||||
rw := &LevelReward{GuildID: guildID}
|
||||
if err := rows.Scan(&rw.Level, &rw.RoleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rw)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
|
||||
const q = `
|
||||
INSERT INTO levelup (guild_id, levelup_channel_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET levelup_channel_id = EXCLUDED.levelup_channel_id
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
|
||||
const q = `
|
||||
UPDATE levelsettings
|
||||
SET message = $1
|
||||
WHERE guild_id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, message, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
||||
const q = `
|
||||
SELECT message
|
||||
FROM levelsettings
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var message sql.NullString
|
||||
switch err := row.Scan(&message); err {
|
||||
case nil:
|
||||
if !message.Valid {
|
||||
return "GG {user}, you reached level {level}!", nil
|
||||
}
|
||||
return message.String, nil
|
||||
case sql.ErrNoRows:
|
||||
return "GG {user}, you reached level {level}!", nil
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/levelrepo"
|
||||
)
|
||||
|
||||
type SettingsProvider interface {
|
||||
IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error)
|
||||
GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error)
|
||||
GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error)
|
||||
SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error
|
||||
SetLevelUpMessage(ctx context.Context, guildID int64, message string) error
|
||||
GetLevelUpMessage(ctx context.Context, guildID int64) (string, error)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
LeveledUp bool
|
||||
NewLevel int
|
||||
RoleRewardID int64 // 0 if none
|
||||
LevelupMessage string // you can fill this later
|
||||
LevelupChannel int64 // 0 means "use current channel"
|
||||
UpdatedXP int64
|
||||
UpdatedLevel int
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
levels *levelrepo.Repo
|
||||
settings SettingsProvider
|
||||
rand *rand.Rand
|
||||
}
|
||||
|
||||
func New(levels *levelrepo.Repo, settings SettingsProvider) *Service {
|
||||
return &Service{
|
||||
levels: levels,
|
||||
settings: settings,
|
||||
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMessage mirrors your Python gain_xp behavior, minus Discord specifics.
|
||||
func (s *Service) HandleMessage(ctx context.Context, guildID, userID int64) (*Result, error) {
|
||||
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
if err != nil || !enabled {
|
||||
return &Result{LeveledUp: false}, err
|
||||
}
|
||||
|
||||
lvl, err := s.levels.GetLevel(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// XP gain rules
|
||||
shouldGainXP := false
|
||||
if lvl.Level < 5 {
|
||||
shouldGainXP = true
|
||||
} else {
|
||||
// random.randint(1, level//4) == 1
|
||||
div := lvl.Level / 4
|
||||
if div < 1 {
|
||||
div = 1
|
||||
}
|
||||
if s.rand.Intn(div)+1 == 1 {
|
||||
shouldGainXP = true
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldGainXP {
|
||||
return &Result{
|
||||
LeveledUp: false,
|
||||
UpdatedXP: lvl.XP,
|
||||
UpdatedLevel: lvl.Level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// This assumes your setXp() adds some fixed XP per hit; adapt amount as needed.
|
||||
xpPerMessage := s.rand.Intn(3) + 1
|
||||
lvl.XP += int64(xpPerMessage)
|
||||
|
||||
res := &Result{
|
||||
LeveledUp: false,
|
||||
UpdatedXP: lvl.XP,
|
||||
UpdatedLevel: lvl.Level,
|
||||
}
|
||||
|
||||
// Level-up rule: if xp >= 100 -> level++
|
||||
if lvl.XP >= 100 {
|
||||
lvl.Level++
|
||||
lvl.XP = 0 // or keep overflow, depending on Python behavior
|
||||
|
||||
res.LeveledUp = true
|
||||
res.NewLevel = lvl.Level
|
||||
res.UpdatedXP = lvl.XP
|
||||
res.UpdatedLevel = lvl.Level
|
||||
|
||||
roleID, ok, err := s.settings.GetRoleRewardForLevel(ctx, guildID, lvl.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
res.RoleRewardID = roleID
|
||||
}
|
||||
|
||||
chID, ok, err := s.settings.GetLevelupChannelID(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
res.LevelupChannel = chID
|
||||
}
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.levels.UpsertLevel(ctx, lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
|
||||
return s.levels.SetLevel(ctx, guildID, userID, level)
|
||||
}
|
||||
|
||||
func (s *Service) GetLevel(ctx context.Context, guildID, userID int64) (*levelrepo.Level, error) {
|
||||
return s.levels.GetLevel(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) TopLevels(ctx context.Context, guildID int64, limit int) ([]*levelrepo.Level, error) {
|
||||
return s.levels.TopLevels(ctx, guildID, limit)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package levelsettings
|
||||
|
||||
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) ToggleLevelSystem(ctx context.Context, guildID int64) (bool, error) {
|
||||
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
newEnabled := !enabled
|
||||
if err := s.settings.SetLevelSystemEnabled(ctx, guildID, newEnabled); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return newEnabled, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
return s.settings.SetLevelSystemEnabled(ctx, guildID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
|
||||
return s.settings.SetLevelUpChannel(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
|
||||
return s.settings.SetLevelUpMessage(ctx, guildID, message)
|
||||
}
|
||||
|
||||
func (s *Service) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
||||
return s.settings.GetLevelUpMessage(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
|
||||
return s.settings.SetRoleRewardForLevel(ctx, guildID, level, roleID)
|
||||
}
|
||||
|
||||
func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*settingsrepo.LevelReward, error) {
|
||||
return s.settings.ListRoleRewards(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
return s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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, meeting *meeting.Service, rps *rps.Service) *Services {
|
||||
s := &Services{
|
||||
Level: level,
|
||||
LevelSettings: levelSettings,
|
||||
Meeting: meeting,
|
||||
RPS: rps,
|
||||
}
|
||||
Global = 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,49 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
||||
if m.Author.Bot || m.GuildID == "" {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
guildID := mustParseInt64(m.GuildID)
|
||||
userID := mustParseInt64(m.Author.ID)
|
||||
res, err := services.Level.HandleMessage(ctx, guildID, userID)
|
||||
if err != nil || !res.LeveledUp {
|
||||
return
|
||||
}
|
||||
// Decide where to send the level-up message
|
||||
targetChannelID := m.ChannelID
|
||||
if res.LevelupChannel != 0 {
|
||||
targetChannelID = strconv.FormatInt(res.LevelupChannel, 10)
|
||||
}
|
||||
content, err := services.LevelSettings.GetLevelUpMessage(ctx, guildID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
content = strings.ReplaceAll(content, "{user}", m.Author.Mention())
|
||||
content = strings.ReplaceAll(content, "{level}", strconv.Itoa(res.NewLevel))
|
||||
|
||||
s.ChannelMessageSend(targetChannelID, content)
|
||||
// Apply role reward if any
|
||||
if res.RoleRewardID != 0 {
|
||||
roleID := strconv.FormatInt(res.RoleRewardID, 10)
|
||||
if err := s.GuildMemberRoleAdd(m.GuildID, m.Author.ID, roleID); err != nil {
|
||||
log.Printf("Failed to add role reward: guild=%s user=%s role=%s err=%v", m.GuildID, m.Author.ID, roleID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseInt64(s string) int64 {
|
||||
id, _ := strconv.ParseInt(s, 10, 64)
|
||||
return id
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package rankcard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/font/opentype"
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
Name string
|
||||
Level int
|
||||
XP int64
|
||||
Percent float64 // 0..1 progress towards next level
|
||||
Avatar string // URL
|
||||
}
|
||||
|
||||
func RenderPNG(ctx context.Context, d Data) ([]byte, error) {
|
||||
const (
|
||||
w = 900
|
||||
h = 300
|
||||
px = 30
|
||||
|
||||
avatarSize = 150
|
||||
barX = 30
|
||||
barY = 220
|
||||
barW = 650
|
||||
barH = 40
|
||||
)
|
||||
|
||||
percent := clamp01(d.Percent)
|
||||
|
||||
dc := gg.NewContext(w, h)
|
||||
dc.SetHexColor("#141414")
|
||||
dc.Clear()
|
||||
|
||||
// Right white polygon
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.NewSubPath()
|
||||
dc.MoveTo(600, 0)
|
||||
dc.LineTo(750, 300)
|
||||
dc.LineTo(900, 300)
|
||||
dc.LineTo(900, 0)
|
||||
dc.ClosePath()
|
||||
dc.Fill()
|
||||
|
||||
// Avatar (circle clip)
|
||||
if img, err := fetchImage(ctx, d.Avatar); err == nil && img != nil {
|
||||
img = resizeImage(img, avatarSize, avatarSize)
|
||||
dc.Push()
|
||||
dc.DrawCircle(px+avatarSize/2, px+avatarSize/2, avatarSize/2)
|
||||
dc.Clip()
|
||||
dc.DrawImage(img, px, px)
|
||||
dc.Pop()
|
||||
// Defensive: ensure no clip leaks past avatar drawing.
|
||||
dc.ResetClip()
|
||||
}
|
||||
|
||||
// Progress bar background
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.DrawRoundedRectangle(barX, barY, barW, barH, 20)
|
||||
dc.Fill()
|
||||
|
||||
// Progress bar fill
|
||||
fillW := barW * percent
|
||||
if fillW > 0 {
|
||||
dc.SetHexColor("#282828")
|
||||
// Keep radius from looking weird on small widths
|
||||
r := math.Min(20, fillW/2)
|
||||
dc.DrawRoundedRectangle(barX, barY, fillW, barH, r)
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
// Text
|
||||
titleFace, smallFace, err := loadFontFaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.SetFontFace(titleFace)
|
||||
dc.DrawStringAnchored(d.Name, 200, 60, 0, 0.5)
|
||||
|
||||
dc.SetLineWidth(2)
|
||||
dc.DrawLine(200, 100, 550, 100)
|
||||
dc.Stroke()
|
||||
|
||||
dc.SetFontFace(smallFace)
|
||||
dc.DrawStringAnchored(
|
||||
fmt.Sprintf("Level - %d | XP - %d | %d%%", d.Level, d.XP, int64(math.Round(percent*100))),
|
||||
200,
|
||||
145,
|
||||
0,
|
||||
0.5,
|
||||
)
|
||||
|
||||
// Watermark to verify latest render is running
|
||||
dc.SetFontFace(smallFace)
|
||||
dc.SetHexColor("#FFCC00")
|
||||
dc.DrawStringAnchored("velox-go rankcard", 890, 290, 1, 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dc.Image()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func loadFontFaces() (title font.Face, small font.Face, err error) {
|
||||
f, err := opentype.Parse(goregular.TTF)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
title, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 40, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
small, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 30, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return title, small, nil
|
||||
}
|
||||
|
||||
func fetchImage(ctx context.Context, url string) (image.Image, error) {
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("empty avatar url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(resp.Body)
|
||||
return img, err
|
||||
}
|
||||
|
||||
func resizeImage(src image.Image, w, h int) image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -9,6 +9,15 @@ import (
|
||||
"velox-bot/internal/bot"
|
||||
"velox-bot/internal/commands"
|
||||
"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() {
|
||||
@@ -18,7 +27,23 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands)
|
||||
db, err := db.NewDB(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating database: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
levelRepo := levelrepo.NewRepo(db)
|
||||
settingsRepo := settingsrepo.NewRepo(db)
|
||||
rpsRepo := rpsrepo.NewRepo(db)
|
||||
levelService := level.New(levelRepo, settingsRepo)
|
||||
levelSettingsService := levelsettings.New(settingsRepo)
|
||||
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 {
|
||||
log.Fatalf("Error creating bot: %v", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 1) Ensure the new table exists
|
||||
CREATE TABLE IF NOT EXISTS levelrewards (
|
||||
guild_id BIGINT NOT NULL,
|
||||
levelreq INT NOT NULL,
|
||||
role BIGINT NOT NULL,
|
||||
PRIMARY KEY (guild_id, levelreq)
|
||||
);
|
||||
|
||||
-- 2) Backfill from legacy columns (only rows that have both values)
|
||||
INSERT INTO levelrewards (guild_id, levelreq, role)
|
||||
SELECT guild_id, levelreq, role
|
||||
FROM levelsettings
|
||||
WHERE role IS NOT NULL
|
||||
AND levelreq IS NOT NULL
|
||||
ON CONFLICT (guild_id, levelreq)
|
||||
DO UPDATE SET role = EXCLUDED.role;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*)
|
||||
FROM levelsettings
|
||||
WHERE role IS NOT NULL AND levelreq IS NOT NULL) AS legacy_reward_rows,
|
||||
(SELECT COUNT(*)
|
||||
FROM levelrewards) AS levelrewards_rows;
|
||||
|
||||
SELECT ls.guild_id, ls.levelreq, ls.role
|
||||
FROM levelsettings ls
|
||||
LEFT JOIN levelrewards lr
|
||||
ON lr.guild_id = ls.guild_id AND lr.levelreq = ls.levelreq
|
||||
WHERE ls.role IS NOT NULL
|
||||
AND ls.levelreq IS NOT NULL
|
||||
AND lr.guild_id IS NULL;
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
-- PostgreSQL schema for Velox
|
||||
-- Use BIGINT for Discord snowflakes, INT for smaller counters, BOOLEAN properly.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levels (
|
||||
guild BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
level INT NOT NULL DEFAULT 0,
|
||||
xp BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (guild, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS twitch (
|
||||
twitch_user TEXT NOT NULL,
|
||||
guild_id BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'not live',
|
||||
PRIMARY KEY (twitch_user, guild_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levelsettings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
role BIGINT,
|
||||
levelreq INT,
|
||||
message TEXT
|
||||
);
|
||||
|
||||
-- Role rewards per level (supports multiple rewards per guild)
|
||||
CREATE TABLE IF NOT EXISTS levelrewards (
|
||||
guild_id BIGINT NOT NULL,
|
||||
levelreq INT NOT NULL,
|
||||
role BIGINT NOT NULL,
|
||||
PRIMARY KEY (guild_id, levelreq)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS welcome (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
welcome_channel_id BIGINT,
|
||||
welcome_message TEXT,
|
||||
welcome_dm TEXT,
|
||||
welcome_gif_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levelup (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
levelup_channel_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS twitch_config (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
twitch_channel_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rps (
|
||||
guild_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
score INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (guild_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS defaultrole (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
role_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logsettings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
log_channel_id BIGINT,
|
||||
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