Compare commits
18
Commits
0354fdc032
...
ft/music
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
d7edfea45e | ||
|
|
db5d23c2ac | ||
|
|
a85ac23a6b | ||
|
|
d177114ddb | ||
|
|
10c2efef7d | ||
|
|
e33eea6d24 | ||
|
|
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,23 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/disgoorg/disgolink/v3 v3.1.0
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3
|
||||
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/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/disgoorg/json v1.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // 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,53 @@
|
||||
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
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/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8=
|
||||
github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8=
|
||||
github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y=
|
||||
github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA=
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
|
||||
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/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=
|
||||
|
||||
+48
-6
@@ -3,6 +3,9 @@ package bot
|
||||
import (
|
||||
"log"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -13,19 +16,37 @@ type Bot struct {
|
||||
GuildID string
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand) (*Bot, error) {
|
||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass 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
|
||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||
discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsGuildVoiceStates |
|
||||
discordgo.IntentsGuildMessageReactions |
|
||||
discordgo.IntentsDirectMessages |
|
||||
discordgo.IntentsDirectMessageReactions
|
||||
|
||||
return &Bot{
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -34,6 +55,8 @@ func (b *Bot) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
||||
|
||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||
for _, cmd := range b.Commands {
|
||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||
@@ -45,8 +68,27 @@ 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)
|
||||
music.OnVoiceStateUpdate(vs)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
|
||||
music.OnVoiceServerUpdate(ev)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
|
||||
events.HandleMessageReactionAdd(s, r, b.Services)
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Close() error {
|
||||
|
||||
@@ -2,20 +2,72 @@ package commands
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/fun"
|
||||
"velox-bot/internal/commands/help"
|
||||
cmdlevel "velox-bot/internal/commands/level"
|
||||
"velox-bot/internal/commands/meeting"
|
||||
cmdmusic "velox-bot/internal/commands/music"
|
||||
"velox-bot/internal/commands/projects"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"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,
|
||||
cmdmusic.Play,
|
||||
cmdmusic.Queue,
|
||||
cmdmusic.Volume,
|
||||
meeting.Meeting,
|
||||
timezone.Timezone,
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
}
|
||||
|
||||
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,
|
||||
"timezone": timezone.TimezoneHandler,
|
||||
"schedule": schedule.ScheduleHandler,
|
||||
"projects": projects.ProjectsHandler,
|
||||
"play": cmdmusic.PlayHandler,
|
||||
"queue": cmdmusic.QueueHandler,
|
||||
"volume": cmdmusic.VolumeHandler,
|
||||
}
|
||||
|
||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||
h(s, i)
|
||||
switch i.Type {
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||
h(s, i)
|
||||
}
|
||||
case discordgo.InteractionMessageComponent:
|
||||
data := i.MessageComponentData()
|
||||
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
|
||||
music.HandleComponent(s, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||
if s == nil || guildID == "" || m == nil {
|
||||
return false
|
||||
}
|
||||
if len(m.Roles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
roles, err := s.GuildRoles(guildID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
djRoleID := ""
|
||||
for _, r := range roles {
|
||||
if r != nil && r.Name == "DJ" {
|
||||
djRoleID = r.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if djRoleID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rid := range m.Roles {
|
||||
if rid == djRoleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Play = &discordgo.ApplicationCommand{
|
||||
Name: "play",
|
||||
Description: "Play a song via Lavalink",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "query",
|
||||
Description: "Song name or URL",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
query := data.Options[0].StringValue()
|
||||
|
||||
// Always acknowledge quickly to avoid "application did not respond".
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Searching...",
|
||||
},
|
||||
})
|
||||
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("You need the **DJ** role to use music commands."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If user provided plain text, turn it into a YouTube search
|
||||
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
|
||||
query = "ytsearch:" + query
|
||||
} else {
|
||||
query = normalizeYouTubeRadioURL(query)
|
||||
}
|
||||
|
||||
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||
if err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
|
||||
if err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := entry.Track.Info.Title
|
||||
author := entry.Track.Info.Author
|
||||
length := entry.Track.Info.Length
|
||||
|
||||
if started {
|
||||
// manager will post now-playing message & manage invalidating old buttons
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||
})
|
||||
} else {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return new(v) }
|
||||
|
||||
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
|
||||
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
|
||||
// It intentionally does NOT strip normal playlist URLs.
|
||||
func normalizeYouTubeRadioURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
host := strings.ToLower(u.Host)
|
||||
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||
return raw
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
v := q.Get("v")
|
||||
if v == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
list := q.Get("list")
|
||||
_, hasStartRadio := q["start_radio"]
|
||||
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
|
||||
return raw
|
||||
}
|
||||
|
||||
u.RawQuery = url.Values{"v": []string{v}}.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||
g, err := s.State.Guild(guildID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot find guild voice state")
|
||||
}
|
||||
for _, vs := range g.VoiceStates {
|
||||
if vs.UserID == userID {
|
||||
return vs, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("you must be in a voice channel")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Queue = &discordgo.ApplicationCommand{
|
||||
Name: "queue",
|
||||
Description: "Show the current music queue",
|
||||
}
|
||||
|
||||
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You need the **DJ** role to use music commands.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
current, rest := music.GetQueue(i.GuildID)
|
||||
|
||||
if current == nil {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "The queue is currently empty.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author)
|
||||
|
||||
var descBuilder strings.Builder
|
||||
if len(rest) > 0 {
|
||||
limit := len(rest)
|
||||
if limit > 10 {
|
||||
limit = 10
|
||||
}
|
||||
for idx, entry := range rest[:limit] {
|
||||
fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy)
|
||||
}
|
||||
if len(rest) > limit {
|
||||
fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit)
|
||||
}
|
||||
} else {
|
||||
descBuilder.WriteString("Nothing else in the queue.")
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: "Music Queue",
|
||||
Description: descBuilder.String(),
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Now Playing",
|
||||
Value: nowPlaying,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Volume = &discordgo.ApplicationCommand{
|
||||
Name: "volume",
|
||||
Description: "Set playback volume (0-150)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "value",
|
||||
Description: "Volume percent (0-150)",
|
||||
Required: true,
|
||||
MinValue: ptrFloat(0),
|
||||
MaxValue: 150,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Updating volume...",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("You need the **DJ** role to use music commands."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("Missing volume value."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
vol := int(data.Options[0].IntValue())
|
||||
if err := music.SetVolume(i.GuildID, vol); err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)),
|
||||
})
|
||||
}
|
||||
|
||||
func ptrFloat(v float64) *float64 { return &v }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/music/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Play *discordgo.ApplicationCommand = public.Play
|
||||
Queue *discordgo.ApplicationCommand = public.Queue
|
||||
Volume *discordgo.ApplicationCommand = public.Volume
|
||||
)
|
||||
|
||||
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
|
||||
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.QueueHandler(s, i)
|
||||
}
|
||||
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/commands/projects/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Projects = &discordgo.ApplicationCommand{
|
||||
Name: "projects",
|
||||
Description: "Manage and list ongoing projects",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "List active projects with creators and helpers",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new active project",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "name",
|
||||
Description: "Project name",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "Short description (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add-helper",
|
||||
Description: "Add a helper to an existing project",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "project-id",
|
||||
Description: "ID of the project",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to add as helper",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireProjectsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleList(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "list":
|
||||
handleList(s, i)
|
||||
case "create":
|
||||
handleCreate(s, i)
|
||||
case "add-helper":
|
||||
handleAddHelper(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 25
|
||||
items, err := services.Global.Projects.ListActiveWithMembers(context.Background(), guildID, limit)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects at the moment.")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Active projects",
|
||||
Description: "",
|
||||
Color: 0x00bcd4,
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item == nil || item.Project == nil {
|
||||
continue
|
||||
}
|
||||
p := item.Project
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = "Unnamed project"
|
||||
}
|
||||
creatorMention := mentionUser(item.Creator)
|
||||
|
||||
var helpersMentions []string
|
||||
for _, h := range item.Helpers {
|
||||
helpersMentions = append(helpersMentions, mentionUser(h))
|
||||
}
|
||||
helpersText := "None"
|
||||
if len(helpersMentions) > 0 {
|
||||
helpersText = strings.Join(helpersMentions, ", ")
|
||||
}
|
||||
|
||||
desc := "**Creator:** " + creatorMention + "\n" +
|
||||
"**Helpers:** " + helpersText
|
||||
if p.Description != "" {
|
||||
desc += "\n" + p.Description
|
||||
}
|
||||
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: name,
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func mentionUser(id int64) string {
|
||||
if id == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
return "<@" + strconv.FormatInt(id, 10) + ">"
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var name, description string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "name":
|
||||
name = o.StringValue()
|
||||
case "description":
|
||||
description = o.StringValue()
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||
}
|
||||
|
||||
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var projectID int64
|
||||
var userID string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "project-id":
|
||||
projectID = o.IntValue()
|
||||
case "user":
|
||||
if u := o.UserValue(s); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid project ID.")
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
|
||||
return
|
||||
}
|
||||
|
||||
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/projects/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Projects *discordgo.ApplicationCommand = public.Projects
|
||||
)
|
||||
|
||||
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 RequireProjectsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Projects == nil {
|
||||
RespondEphemeral(s, i, "Projects service is not available.")
|
||||
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 projects.")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Schedule = &discordgo.ApplicationCommand{
|
||||
Name: "schedule",
|
||||
Description: "Schedule 1:1 sessions",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new session with a user",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to invite",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "When (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "What is this session about? (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "List your upcoming sessions",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "reschedule",
|
||||
Description: "Reschedule an existing session",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "id",
|
||||
Description: "ID of the session (from /schedule list)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "New datetime (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleList(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "create":
|
||||
handleCreate(s, i)
|
||||
case "list":
|
||||
handleList(s, i)
|
||||
case "reschedule":
|
||||
handleReschedule(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var (
|
||||
targetUser *discordgo.User
|
||||
whenStr string
|
||||
desc string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "user":
|
||||
targetUser = o.UserValue(s)
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
case "description":
|
||||
desc = o.StringValue()
|
||||
}
|
||||
}
|
||||
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.ID == i.Member.User.ID {
|
||||
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid target user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve requester's timezone and parse input using explicit layout in that zone.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect layout "2006-01-02 15:04" in the user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
ctx := context.Background()
|
||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create schedule.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send DM to invitee.
|
||||
dmCh, err := s.UserChannelCreate(targetUser.ID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "New session request",
|
||||
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
utcStr := when.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: utcStr,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if desc != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add reactions for interaction.
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
|
||||
}
|
||||
|
||||
// Store linkage between message and schedule.
|
||||
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your sessions.")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
shared.RespondEphemeral(s, i, "You have no upcoming sessions.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, sch := range items {
|
||||
role := "Requester"
|
||||
otherID := sch.InviteeID
|
||||
if sch.InviteeID == userID {
|
||||
role = "Invitee"
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
status := string(sch.Status)
|
||||
whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status)
|
||||
if sch.Description != "" {
|
||||
line += " " + sch.Description + "\n"
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, b.String())
|
||||
}
|
||||
|
||||
func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var (
|
||||
idVal int64
|
||||
whenStr string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "id":
|
||||
idVal = o.IntValue()
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
}
|
||||
}
|
||||
if idVal <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid session ID.")
|
||||
return
|
||||
}
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := services.Global.Schedule.GetByID(ctx, idVal)
|
||||
if err != nil || sch == nil {
|
||||
shared.RespondEphemeral(s, i, "Session not found.")
|
||||
return
|
||||
}
|
||||
if sch.GuildID != guildID {
|
||||
shared.RespondEphemeral(s, i, "This session belongs to another server.")
|
||||
return
|
||||
}
|
||||
if userID != sch.RequesterID && userID != sch.InviteeID {
|
||||
shared.RespondEphemeral(s, i, "You are not part of this session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Use rescheduler's timezone for parsing.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify participants via DM and send new request DM to invitee.
|
||||
whenFmt := when.Format("2006-01-02 15:04")
|
||||
|
||||
otherID := sch.InviteeID
|
||||
if userID == sch.InviteeID {
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
|
||||
// DM both about the change, including their local times if available.
|
||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
||||
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
|
||||
if err != nil {
|
||||
locU = time.UTC
|
||||
zoneU = "UTC"
|
||||
}
|
||||
localU := when.In(locU).Format("2006-01-02 15:04")
|
||||
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
|
||||
if sch.Description != "" {
|
||||
msg += "\nTopic: " + sch.Description
|
||||
}
|
||||
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = s.ChannelMessageSend(ch.ID, msg)
|
||||
}
|
||||
|
||||
// Send new "request" DM to invitee with reactions again.
|
||||
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
|
||||
inviteeUser, err := s.User(inviteeStr)
|
||||
if err == nil && inviteeUser != nil {
|
||||
dmCh, err := s.UserChannelCreate(inviteeStr)
|
||||
if err == nil {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Rescheduled session",
|
||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
||||
Color: 0xffc107,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if errTZ != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err == nil && msgObj != nil {
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
|
||||
}
|
||||
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/schedule/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Schedule *discordgo.ApplicationCommand = public.Schedule
|
||||
)
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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 RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Schedule == nil {
|
||||
RespondEphemeral(s, i, "Scheduling 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/timezone/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Timezone = &discordgo.ApplicationCommand{
|
||||
Name: "timezone",
|
||||
Description: "Configure and view your timezone",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set",
|
||||
Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "zone",
|
||||
Description: "Timezone (e.g., Europe/Lisbon, America/New_York)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "show",
|
||||
Description: "Show your current timezone",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireUserSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleShow(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "set":
|
||||
handleSet(s, i)
|
||||
case "show":
|
||||
handleShow(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var zone string
|
||||
for _, o := range opt.Options {
|
||||
if o.Name == "zone" {
|
||||
zone = o.StringValue()
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
shared.RespondEphemeral(s, i, "Timezone cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate via service (which uses time.LoadLocation).
|
||||
if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").")
|
||||
}
|
||||
|
||||
func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your timezone.")
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
|
||||
if userDefined {
|
||||
shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").")
|
||||
} else {
|
||||
shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package timezone
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/timezone/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Timezone *discordgo.ApplicationCommand = public.Timezone
|
||||
)
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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 RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.UserSettings == nil {
|
||||
RespondEphemeral(s, i, "User settings service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) {
|
||||
var idStr string
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
idStr = i.Member.User.ID
|
||||
} else if i.User != nil {
|
||||
idStr = i.User.ID
|
||||
}
|
||||
if idStr == "" {
|
||||
return 0, false
|
||||
}
|
||||
uid, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
@@ -33,9 +36,20 @@ 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")
|
||||
}
|
||||
|
||||
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
||||
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
||||
|
||||
return &Config{
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, 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,158 @@
|
||||
package projectsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Name string
|
||||
Description string
|
||||
IsActive bool
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
type ProjectWithMembers struct {
|
||||
Project *Project
|
||||
Creator int64
|
||||
Helpers []int64
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO projects (guild_id, name, description, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID, name, description, creatorID).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const qMember = `
|
||||
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||
VALUES ($1, $2, TRUE)
|
||||
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||
`
|
||||
if _, err := r.db.ExecContext(ctx, qMember, id, creatorID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||
const q = `
|
||||
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||
VALUES ($1, $2, FALSE)
|
||||
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, projectID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||
const q = `
|
||||
SELECT 1
|
||||
FROM projects
|
||||
WHERE guild_id = $1 AND id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, projectID)
|
||||
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) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
|
||||
const qProjects = `
|
||||
SELECT id, name, description, created_by
|
||||
FROM projects
|
||||
WHERE guild_id = $1 AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, qProjects, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*ProjectWithMembers
|
||||
for rows.Next() {
|
||||
p := &Project{GuildID: guildID, IsActive: true}
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &ProjectWithMembers{
|
||||
Project: p,
|
||||
Creator: p.CreatedBy,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Load members per project.
|
||||
const qMembers = `
|
||||
SELECT project_id, user_id, is_creator
|
||||
FROM project_members
|
||||
WHERE project_id = ANY($1)
|
||||
`
|
||||
ids := make([]int64, len(out))
|
||||
for i, p := range out {
|
||||
ids[i] = p.Project.ID
|
||||
}
|
||||
|
||||
rowsM, err := r.db.QueryContext(ctx, qMembers, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rowsM.Close()
|
||||
|
||||
index := make(map[int64]*ProjectWithMembers, len(out))
|
||||
for _, p := range out {
|
||||
index[p.Project.ID] = p
|
||||
}
|
||||
|
||||
for rowsM.Next() {
|
||||
var pid, uid int64
|
||||
var isCreator bool
|
||||
if err := rowsM.Scan(&pid, &uid, &isCreator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, ok := index[pid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isCreator {
|
||||
item.Creator = uid
|
||||
} else {
|
||||
item.Helpers = append(item.Helpers, uid)
|
||||
}
|
||||
}
|
||||
if err := rowsM.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,189 @@
|
||||
package schedulerepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type ScheduleStatus string
|
||||
|
||||
const (
|
||||
StatusPending ScheduleStatus = "pending"
|
||||
StatusAccepted ScheduleStatus = "accepted"
|
||||
StatusDeclined ScheduleStatus = "declined"
|
||||
StatusRescheduleRequested ScheduleStatus = "reschedule_requested"
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
RequesterID int64
|
||||
InviteeID int64
|
||||
ScheduledAt time.Time
|
||||
Status ScheduleStatus
|
||||
Description string
|
||||
ReminderSent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
const q = `
|
||||
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (schedule_id, message_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
|
||||
FROM schedules s
|
||||
JOIN schedule_messages m ON m.schedule_id = s.id
|
||||
WHERE m.message_id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, messageID)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, status, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE guild_id = $1
|
||||
AND (requester_id = $2 OR invitee_id = $2)
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET scheduled_at = $1,
|
||||
status = 'pending',
|
||||
reminder_sent = FALSE
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, when, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded.
|
||||
func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE reminder_sent = FALSE
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
AND scheduled_at > $1
|
||||
AND scheduled_at <= $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET reminder_sent = TRUE
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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,44 @@
|
||||
package usersettingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
const q = `
|
||||
INSERT INTO user_settings (user_id, timezone)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET timezone = EXCLUDED.timezone
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, userID, zone)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) {
|
||||
const q = `
|
||||
SELECT timezone
|
||||
FROM user_settings
|
||||
WHERE user_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, userID)
|
||||
var zone string
|
||||
switch err := row.Scan(&zone); err {
|
||||
case nil:
|
||||
return zone, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return "", false, nil
|
||||
default:
|
||||
return "", false, 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,32 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/db/repos/projectsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *projectsrepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *projectsrepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||
return s.repo.CreateProject(ctx, guildID, creatorID, name, description)
|
||||
}
|
||||
|
||||
func (s *Service) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||
return s.repo.AddHelper(ctx, projectID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.ProjectWithMembers, error) {
|
||||
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
|
||||
}
|
||||
|
||||
@@ -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,53 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *schedulerepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *schedulerepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
|
||||
}
|
||||
|
||||
func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
|
||||
}
|
||||
|
||||
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByMessageID(ctx, messageID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListForUser(ctx, guildID, userID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListDueForReminder(ctx, now, ahead)
|
||||
}
|
||||
|
||||
func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
return s.repo.MarkReminded(ctx, scheduleID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
return s.repo.Reschedule(ctx, id, when)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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/schedule"
|
||||
"velox-bot/internal/db/services/usersettings"
|
||||
"velox-bot/internal/db/services/projects"
|
||||
"velox-bot/internal/db/services/rps"
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Level *level.Service
|
||||
LevelSettings *levelsettings.Service
|
||||
Meeting *meeting.Service
|
||||
Schedule *schedule.Service
|
||||
UserSettings *usersettings.Service
|
||||
Projects *projects.Service
|
||||
RPS *rps.Service
|
||||
}
|
||||
|
||||
var Global *Services
|
||||
|
||||
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services {
|
||||
s := &Services{
|
||||
Level: level,
|
||||
LevelSettings: levelSettings,
|
||||
Meeting: meeting,
|
||||
Schedule: schedule,
|
||||
UserSettings: userSettings,
|
||||
Projects: projects,
|
||||
RPS: rps,
|
||||
}
|
||||
Global = s
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package usersettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *usersettingsrepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *usersettingsrepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
// validate zone before storing
|
||||
if _, err := time.LoadLocation(zone); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.SetTimezone(ctx, userID, zone)
|
||||
}
|
||||
|
||||
// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined.
|
||||
func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) {
|
||||
zone, ok, err := s.repo.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
return time.UTC, "UTC", false, err
|
||||
}
|
||||
if !ok || zone == "" {
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
// fallback to UTC but indicate not user-defined
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
return loc, zone, true, nil
|
||||
}
|
||||
|
||||
@@ -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,95 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
|
||||
// ✅ accept, ❌ decline, 🔁 request reschedule.
|
||||
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil {
|
||||
return
|
||||
}
|
||||
if r == nil || r.UserID == "" || r.MessageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore bot reactions.
|
||||
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
|
||||
return
|
||||
}
|
||||
|
||||
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
|
||||
if err != nil || msgID64 == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
|
||||
if err != nil || sch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only invitee can react to change status.
|
||||
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending can be transitioned by reaction.
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
var newStatus schedulerepo.ScheduleStatus
|
||||
switch r.Emoji.Name {
|
||||
case "✅":
|
||||
newStatus = schedulerepo.StatusAccepted
|
||||
case "❌":
|
||||
newStatus = schedulerepo.StatusDeclined
|
||||
case "🔁":
|
||||
newStatus = schedulerepo.StatusRescheduleRequested
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
|
||||
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||
schedulerepo.StatusAccepted: "accepted ✅",
|
||||
schedulerepo.StatusDeclined: "declined ❌",
|
||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||
}[newStatus]
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
// Notify requester with local time if configured.
|
||||
if svc.UserSettings != nil {
|
||||
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
|
||||
|
||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
||||
dmCh, err := s.UserChannelCreate(requesterID)
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// StartScheduleReminderLoop runs a background loop that periodically checks for
|
||||
// upcoming sessions and sends DM reminders to all participants.
|
||||
func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
interval = time.Minute // check every minute
|
||||
remindBefore = 10 * time.Minute // remind 10 minutes before
|
||||
initialDelay = 10 * time.Second
|
||||
)
|
||||
|
||||
go func() {
|
||||
// small delay so the bot can fully start
|
||||
time.Sleep(initialDelay)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
ctx := context.Background()
|
||||
|
||||
// Find all sessions that start in the next `remindBefore` window and haven't been reminded.
|
||||
schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore)
|
||||
if err != nil {
|
||||
log.Printf("schedule: reminder query failed: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, sch := range schedules {
|
||||
if sch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
desc := sch.Description
|
||||
|
||||
// Notify requester with local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
locReq = sch.ScheduledAt.UTC().Location()
|
||||
zoneReq = "UTC"
|
||||
}
|
||||
localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04")
|
||||
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID)
|
||||
if desc != "" {
|
||||
base += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.RequesterID, base); err != nil {
|
||||
log.Printf("schedule: failed to DM requester reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify invitee with their local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if err != nil {
|
||||
locInv = sch.ScheduledAt.UTC().Location()
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04")
|
||||
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID)
|
||||
if desc != "" {
|
||||
baseInvitee += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil {
|
||||
log.Printf("schedule: failed to DM invitee reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil {
|
||||
log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func dmUser(s *discordgo.Session, userID int64, content string) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
uid := strconv.FormatInt(userID, 10)
|
||||
ch, err := s.UserChannelCreate(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.ChannelMessageSend(ch.ID, content)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
// DJ role is required for all music controls.
|
||||
if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You need the **DJ** role to use music commands.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customID := i.MessageComponentData().CustomID
|
||||
parts := strings.Split(customID, ":")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
action := parts[1]
|
||||
guildID := i.GuildID
|
||||
|
||||
// Ignore controls from old now-playing messages
|
||||
currentMsgID := CurrentNowPlayingMessageID(guildID)
|
||||
if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "These controls are outdated.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately to avoid "This interaction failed".
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredMessageUpdate,
|
||||
})
|
||||
|
||||
var err error
|
||||
content := ""
|
||||
|
||||
switch action {
|
||||
case "pause":
|
||||
err = Pause(guildID, true)
|
||||
content = "Paused."
|
||||
case "resume":
|
||||
err = Pause(guildID, false)
|
||||
content = "Resumed."
|
||||
case "toggle_pause":
|
||||
paused, _ := pausedAndVolume(guildID)
|
||||
err = Pause(guildID, !paused)
|
||||
if paused {
|
||||
content = "Resumed."
|
||||
} else {
|
||||
content = "Paused."
|
||||
}
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
case "skip":
|
||||
err = Skip(guildID)
|
||||
content = "Skipped."
|
||||
case "stop":
|
||||
err = Stop(guildID)
|
||||
content = "Stopped."
|
||||
case "repeat_song":
|
||||
rs, rq := ToggleRepeatSong(guildID)
|
||||
_ = rs
|
||||
_ = rq
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
if rs {
|
||||
content = "Repeat song enabled."
|
||||
} else {
|
||||
content = "Repeat song disabled."
|
||||
}
|
||||
case "repeat_queue":
|
||||
rs, rq := ToggleRepeatQueue(guildID)
|
||||
_ = rs
|
||||
_ = rq
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
if rq {
|
||||
content = "Repeat queue enabled."
|
||||
} else {
|
||||
content = "Repeat queue disabled."
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
content = "Done."
|
||||
}
|
||||
if err != nil {
|
||||
content = "Error: " + err.Error()
|
||||
}
|
||||
|
||||
// send ephemeral confirmation
|
||||
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||
Content: content,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
)
|
||||
|
||||
type TrackEntry struct {
|
||||
Track lavalink.Track
|
||||
RequestedBy string
|
||||
}
|
||||
|
||||
type guildPlayer struct {
|
||||
Player disgolink.Player
|
||||
Queue []TrackEntry
|
||||
RepeatSong bool
|
||||
RepeatQueue bool
|
||||
Paused bool
|
||||
Volume int
|
||||
IdleSince time.Time
|
||||
TextChannelID string
|
||||
NowPlayingMsgID string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
client disgolink.Client
|
||||
session *discordgo.Session
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]*guildPlayer
|
||||
}
|
||||
|
||||
var manager *Manager
|
||||
|
||||
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
|
||||
if lavalinkHost == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, err := snowflake.Parse(appID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse app id for lavalink: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
||||
session: session,
|
||||
players: make(map[string]*guildPlayer),
|
||||
}
|
||||
|
||||
u, err := url.Parse(lavalinkHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse lavalink host: %w", err)
|
||||
}
|
||||
|
||||
secure := u.Scheme == "https" || u.Scheme == "wss"
|
||||
password := lavalinkPass
|
||||
if password == "" {
|
||||
password = "youshallnotpass"
|
||||
}
|
||||
|
||||
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||
Name: "main",
|
||||
Address: u.Host,
|
||||
Password: password,
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add lavalink node: %w", err)
|
||||
}
|
||||
|
||||
manager = m
|
||||
go idleDisconnectLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func idleDisconnectLoop() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if manager == nil || manager.session == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var toDisconnect []string
|
||||
|
||||
manager.mu.Lock()
|
||||
for guildID, gp := range manager.players {
|
||||
if gp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're paused or have anything queued/playing, clear idle timer.
|
||||
if gp.Paused || len(gp.Queue) > 0 {
|
||||
gp.IdleSince = time.Time{}
|
||||
continue
|
||||
}
|
||||
|
||||
if gp.IdleSince.IsZero() {
|
||||
gp.IdleSince = now
|
||||
continue
|
||||
}
|
||||
|
||||
if now.Sub(gp.IdleSince) >= time.Minute {
|
||||
toDisconnect = append(toDisconnect, guildID)
|
||||
gp.IdleSince = time.Time{}
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
for _, guildID := range toDisconnect {
|
||||
// Best-effort: disconnect. Safe even if we're already disconnected.
|
||||
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
if vs.UserID != manager.session.State.User.ID {
|
||||
return
|
||||
}
|
||||
|
||||
guildID := snowflake.MustParse(vs.GuildID)
|
||||
var channelID *snowflake.ID
|
||||
if vs.ChannelID != "" {
|
||||
id := snowflake.MustParse(vs.ChannelID)
|
||||
channelID = &id
|
||||
}
|
||||
|
||||
manager.client.OnVoiceStateUpdate(
|
||||
context.Background(),
|
||||
guildID,
|
||||
channelID,
|
||||
vs.SessionID,
|
||||
)
|
||||
}
|
||||
|
||||
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
endpoint := ""
|
||||
if ev.Endpoint != "" {
|
||||
endpoint = ev.Endpoint
|
||||
}
|
||||
|
||||
manager.client.OnVoiceServerUpdate(
|
||||
context.Background(),
|
||||
snowflake.MustParse(ev.GuildID),
|
||||
ev.Token,
|
||||
endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if gp, ok := manager.players[guildID]; ok {
|
||||
return gp
|
||||
}
|
||||
|
||||
player := manager.client.Player(snowflake.MustParse(guildID))
|
||||
gp := &guildPlayer{
|
||||
Player: player,
|
||||
Queue: make([]TrackEntry, 0),
|
||||
Volume: 100,
|
||||
}
|
||||
manager.players[guildID] = gp
|
||||
return gp
|
||||
}
|
||||
|
||||
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
|
||||
if manager == nil {
|
||||
return nil, false, fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
|
||||
// Join voice channel
|
||||
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
||||
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
||||
}
|
||||
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
if textChannelID != "" {
|
||||
manager.mu.Lock()
|
||||
gp.TextChannelID = textChannelID
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
var loadedTracks []lavalink.Track
|
||||
|
||||
manager.client.BestNode().LoadTracksHandler(
|
||||
context.Background(),
|
||||
query,
|
||||
disgolink.NewResultHandler(
|
||||
func(track lavalink.Track) {
|
||||
loadedTracks = append(loadedTracks, track)
|
||||
},
|
||||
func(playlist lavalink.Playlist) {
|
||||
if len(playlist.Tracks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
||||
selected := playlist.Info.SelectedTrack
|
||||
if selected >= 0 && selected < len(playlist.Tracks) {
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
||||
return
|
||||
}
|
||||
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
||||
},
|
||||
func(tracks []lavalink.Track) {
|
||||
if len(tracks) > 0 {
|
||||
loadedTracks = append(loadedTracks, tracks[0])
|
||||
}
|
||||
},
|
||||
func() {
|
||||
},
|
||||
func(err error) {
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if len(loadedTracks) == 0 {
|
||||
return nil, false, fmt.Errorf("no tracks found for query")
|
||||
}
|
||||
|
||||
// Some sources may return unplayable entries; pick the first with an encoded track.
|
||||
firstPlayableIdx := 0
|
||||
for idx := range loadedTracks {
|
||||
if loadedTracks[idx].Encoded != "" {
|
||||
firstPlayableIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
||||
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
|
||||
}
|
||||
|
||||
entries := make([]TrackEntry, len(loadedTracks))
|
||||
for idx, t := range loadedTracks {
|
||||
entries[idx] = TrackEntry{
|
||||
Track: t,
|
||||
RequestedBy: requester,
|
||||
}
|
||||
}
|
||||
firstEntry := entries[0]
|
||||
|
||||
manager.mu.Lock()
|
||||
shouldStart := len(gp.Queue) == 0
|
||||
gp.Queue = append(gp.Queue, entries...)
|
||||
gp.IdleSince = time.Time{}
|
||||
manager.mu.Unlock()
|
||||
|
||||
if shouldStart {
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
|
||||
return nil, false, fmt.Errorf("start track: %w", err)
|
||||
}
|
||||
if gp.TextChannelID != "" {
|
||||
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
||||
}
|
||||
}
|
||||
|
||||
return &firstEntry, shouldStart, nil
|
||||
}
|
||||
|
||||
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
if !event.Reason.MayStartNext() {
|
||||
return
|
||||
}
|
||||
|
||||
guildID := player.GuildID().String()
|
||||
|
||||
manager.mu.Lock()
|
||||
gp, ok := manager.players[guildID]
|
||||
if !ok || len(gp.Queue) == 0 {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// repeat current track
|
||||
if gp.RepeatSong {
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return
|
||||
}
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// pop current and optionally cycle it to end
|
||||
finished := gp.Queue[0]
|
||||
gp.Queue = gp.Queue[1:]
|
||||
if gp.RepeatQueue {
|
||||
gp.Queue = append(gp.Queue, finished)
|
||||
}
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
return
|
||||
}
|
||||
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return
|
||||
}
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
}
|
||||
|
||||
func Pause(guildID string, pause bool) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.Paused = pause
|
||||
if pause {
|
||||
gp.IdleSince = time.Time{}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
||||
}
|
||||
|
||||
func Skip(guildID string) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
if len(gp.Queue) == 0 {
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
// skip always advances regardless of repeat song
|
||||
skipped := gp.Queue[0]
|
||||
gp.Queue = gp.Queue[1:]
|
||||
if gp.RepeatQueue {
|
||||
gp.Queue = append(gp.Queue, skipped)
|
||||
}
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return err
|
||||
}
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Stop(guildID string) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.Queue = nil
|
||||
gp.Paused = false
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
|
||||
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
||||
if manager == nil {
|
||||
return nil, nil
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
current := gp.Queue[0]
|
||||
rest := make([]TrackEntry, len(gp.Queue)-1)
|
||||
copy(rest, gp.Queue[1:])
|
||||
return ¤t, rest
|
||||
}
|
||||
|
||||
func ToggleRepeatSong(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
gp.RepeatSong = !gp.RepeatSong
|
||||
if gp.RepeatSong {
|
||||
gp.RepeatQueue = false
|
||||
}
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
gp.RepeatQueue = !gp.RepeatQueue
|
||||
if gp.RepeatQueue {
|
||||
gp.RepeatSong = false
|
||||
}
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
func repeatFlags(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
func pausedAndVolume(guildID string) (bool, int) {
|
||||
if manager == nil {
|
||||
return false, 100
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
vol := gp.Volume
|
||||
if vol <= 0 {
|
||||
vol = 100
|
||||
}
|
||||
return gp.Paused, vol
|
||||
}
|
||||
|
||||
func SetVolume(guildID string, volume int) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
if volume < 0 {
|
||||
volume = 0
|
||||
}
|
||||
if volume > 150 {
|
||||
volume = 150
|
||||
}
|
||||
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.Volume = volume
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh the embed so the displayed volume matches.
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
current, _ := GetQueue(guildID)
|
||||
if current != nil {
|
||||
postNowPlaying(guildID, textChannelID, *current)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CurrentNowPlayingMessageID(guildID string) string {
|
||||
if manager == nil {
|
||||
return ""
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return gp.NowPlayingMsgID
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
)
|
||||
|
||||
func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent {
|
||||
repeatSongStyle := discordgo.SecondaryButton
|
||||
if repeatSong {
|
||||
repeatSongStyle = discordgo.SuccessButton
|
||||
}
|
||||
repeatQueueStyle := discordgo.SecondaryButton
|
||||
if repeatQueue {
|
||||
repeatQueueStyle = discordgo.SuccessButton
|
||||
}
|
||||
|
||||
return []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Style: discordgo.SecondaryButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]},
|
||||
CustomID: "music:toggle_pause",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: discordgo.SecondaryButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "⏭️"},
|
||||
CustomID: "music:skip",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: repeatSongStyle,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "🔂"},
|
||||
CustomID: "music:repeat_song",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: repeatQueueStyle,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "🔁"},
|
||||
CustomID: "music:repeat_queue",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: discordgo.DangerButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "✖️"},
|
||||
CustomID: "music:stop",
|
||||
Disabled: disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func disableNowPlaying(textChannelID, messageID string) {
|
||||
if manager == nil || textChannelID == "" || messageID == "" {
|
||||
return
|
||||
}
|
||||
disabled := nowPlayingComponents(true, false, false, false)
|
||||
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: textChannelID,
|
||||
ID: messageID,
|
||||
Components: &disabled,
|
||||
})
|
||||
}
|
||||
|
||||
func postNowPlaying(guildID, textChannelID string, entry TrackEntry) {
|
||||
if manager == nil || textChannelID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
gp, ok := manager.players[guildID]
|
||||
var oldMsgID string
|
||||
if ok {
|
||||
oldMsgID = gp.NowPlayingMsgID
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
if oldMsgID != "" {
|
||||
disableNowPlaying(textChannelID, oldMsgID)
|
||||
}
|
||||
|
||||
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||
paused, volume := pausedAndVolume(guildID)
|
||||
|
||||
info := entry.Track.Info
|
||||
title := info.Title
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Author: &discordgo.MessageEmbedAuthor{
|
||||
Name: info.Author,
|
||||
IconURL: sourceIconURL(info.SourceName),
|
||||
},
|
||||
Title: title,
|
||||
Color: 0x2B2D31, // matches Discord dark-ish embed accent
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true},
|
||||
{Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true},
|
||||
{Name: "Requested by", Value: entry.RequestedBy, Inline: true},
|
||||
},
|
||||
}
|
||||
|
||||
if info.URI != nil && *info.URI != "" {
|
||||
embed.URL = *info.URI
|
||||
}
|
||||
|
||||
if art := artworkURL(info); art != "" {
|
||||
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art}
|
||||
}
|
||||
|
||||
msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
// Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks).
|
||||
gp = manager.players[guildID]
|
||||
if gp == nil {
|
||||
gp = &guildPlayer{
|
||||
Player: manager.client.Player(snowflake.MustParse(guildID)),
|
||||
Queue: make([]TrackEntry, 0),
|
||||
}
|
||||
manager.players[guildID] = gp
|
||||
}
|
||||
gp.TextChannelID = textChannelID
|
||||
gp.NowPlayingMsgID = msg.ID
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func artworkURL(info lavalink.TrackInfo) string {
|
||||
if info.ArtworkURL != nil && *info.ArtworkURL != "" {
|
||||
return *info.ArtworkURL
|
||||
}
|
||||
// Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback.
|
||||
if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" {
|
||||
return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatDuration(d lavalink.Duration, isStream bool) string {
|
||||
if isStream {
|
||||
return "Live"
|
||||
}
|
||||
secs := d.Seconds()
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h := secs / 3600
|
||||
m := (secs % 3600) / 60
|
||||
s := secs % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
func sourceIconURL(source string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "youtube":
|
||||
// Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds)
|
||||
return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com"
|
||||
case "soundcloud":
|
||||
return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package music
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||
if s == nil || guildID == "" || m == nil {
|
||||
return false
|
||||
}
|
||||
if len(m.Roles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
roles, err := s.GuildRoles(guildID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
djRoleID := ""
|
||||
for _, r := range roles {
|
||||
if r != nil && r.Name == "DJ" {
|
||||
djRoleID = r.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if djRoleID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rid := range m.Roles {
|
||||
if rid == djRoleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package music
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func updateNowPlayingButtons(channelID, messageID string, guildID string) {
|
||||
if manager == nil || channelID == "" || messageID == "" {
|
||||
return
|
||||
}
|
||||
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||
paused, _ := pausedAndVolume(guildID)
|
||||
comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused)
|
||||
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: channelID,
|
||||
ID: messageID,
|
||||
Components: &comps,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,21 @@ 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/projectsrepo"
|
||||
"velox-bot/internal/db/repos/rpsrepo"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/repos/settingsrepo"
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
"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/schedule"
|
||||
"velox-bot/internal/db/services/usersettings"
|
||||
"velox-bot/internal/db/services/projects"
|
||||
"velox-bot/internal/db/services/rps"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -18,7 +33,29 @@ 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)
|
||||
projectsRepo := projectsrepo.NewRepo(db)
|
||||
scheduleRepo := schedulerepo.NewRepo(db)
|
||||
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||
levelService := level.New(levelRepo, settingsRepo)
|
||||
levelSettingsService := levelsettings.New(settingsRepo)
|
||||
meetingService := meeting.New(settingsRepo)
|
||||
scheduleService := schedule.New(scheduleRepo)
|
||||
userSettingsService := usersettings.New(userSettingsRepo)
|
||||
projectsService := projects.New(projectsRepo)
|
||||
rpsService := rps.New(rpsRepo)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
|
||||
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, 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;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
-- 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)
|
||||
);
|
||||
|
||||
-- Projects & members
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
is_creator BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
--
|
||||
-- Scheduling (one-to-one sessions)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
requester_id BIGINT NOT NULL,
|
||||
invitee_id BIGINT NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
||||
description TEXT,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_messages (
|
||||
schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
|
||||
message_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (schedule_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
timezone TEXT NOT NULL
|
||||
);
|
||||
Reference in New Issue
Block a user