18 Commits
Author SHA1 Message Date
FernandoJVideira ded31409e2 Merge branch 'dev' into ft/music 2026-03-18 03:52:53 +00:00
FernandoJVideira ae3d574c68 Merge pull request 'ft/schedule' (#4) from ft/schedule into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/4
2026-03-18 03:48:14 +00:00
FernandoJVideira d7edfea45e feat: integrate music commands with Lavalink support
- Added new music commands: play, queue, and volume.
- Implemented music management using disgolink for Lavalink integration.
- Updated bot initialization to include Lavalink host and password.
- Enhanced interaction handling for music commands, requiring DJ role for usage.
- Introduced now playing message with interactive buttons for controlling playback.
- Updated dependencies in go.mod for disgolink and snowflake.
2026-03-18 03:47:42 +00:00
FernandoJVideira db5d23c2ac feat: timezone awarenwss 2026-03-18 01:35:09 +00:00
FernandoJVideira a85ac23a6b feat: basic schedule 2026-03-18 01:11:53 +00:00
FernandoJVideira d177114ddb Merge pull request 'feat: project add & list' (#3) from ft/projects into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/3
2026-03-18 00:41:04 +00:00
FernandoJVideira 10c2efef7d feat: project add & list 2026-03-18 00:40:13 +00:00
FernandoJVideira e33eea6d24 Merge pull request 'ft/meetings' (#2) from ft/meetings into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/2
2026-03-18 00:25:32 +00:00
FernandoJVideira 76aeca3de6 feat: meeting room locking 2026-03-17 17:11:22 +00:00
FernandoJVideira c4dd7302bc feat: meeting rooms 2026-03-17 16:59:26 +00:00
FernandoJVideira 0b36bc9e93 feat: rps commands 2026-03-17 16:44:18 +00:00
FernandoJVideira dee5008c60 feat: joke & dice command 2026-03-17 16:34:12 +00:00
FernandoJVideira a664ae15fd Merge pull request 'ft/lvl_system' (#1) from ft/lvl_system into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/1
2026-03-17 16:32:52 +00:00
FernandoJVideira 825dca34f0 feat: leveling system .gitignore update 2026-03-17 15:56:12 +00:00
FernandoJVideira d399122cab feat: leveling system .gitignore update 2026-03-17 15:55:47 +00:00
FernandoJVideira 0f49c79567 feat: leveling system refractor 2026-03-17 15:54:17 +00:00
FernandoJVideira 8ba26f8bb9 feat: leveling system 2026-03-17 15:35:25 +00:00
FernandoJVideira 2bce787c05 feat: leveling system 2026-03-17 15:17:36 +00:00
76 changed files with 7164 additions and 23 deletions
+58
View File
@@ -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
View File
@@ -1,2 +1,3 @@
.env
.cursor
.cursor
tmp/
+8
View File
@@ -0,0 +1,8 @@
up:
docker compose up -d
down:
docker compose down
start: up
air
+23
View File
@@ -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:
+13 -1
View File
@@ -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
)
+40 -1
View File
@@ -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
View File
@@ -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 {
+56 -4
View File
@@ -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)
}
}
}
+22
View File
@@ -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!")
}
}
+62
View File
@@ -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!")
}
+153
View File
@@ -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{
+106
View File
@@ -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},
},
})
}
+73
View File
@@ -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},
},
})
}
+33
View File
@@ -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)
}
+24
View File
@@ -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,
},
})
}
+125
View File
@@ -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)
}
+41
View File
@@ -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())
}
+43
View File
@@ -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},
},
})
}
+94
View File
@@ -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),
},
},
},
})
}
+68
View File
@@ -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},
},
})
}
+26
View File
@@ -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) }
+95
View File
@@ -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
}
+332
View File
@@ -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
}
+14
View File
@@ -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
}
+130
View File
@@ -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")
}
+77
View File
@@ -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,
},
},
},
},
},
})
}
+63
View File
@@ -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 }
+20
View File
@@ -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)+"`.")
}
+14
View File
@@ -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.")
}
+14
View File
@@ -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.")
}
}
+14
View File
@@ -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
}
+20 -6
View File
@@ -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
}
+26
View File
@@ -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
}
+94
View File
@@ -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
}
+158
View File
@@ -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
}
+76
View File
@@ -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
}
+189
View File
@@ -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
}
+262
View File
@@ -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
}
}
+134
View File
@@ -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)
}
+40
View File
@@ -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)
}
+32
View File
@@ -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)
}
+136
View File
@@ -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")
+53
View File
@@ -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)
}
+37
View File
@@ -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
}
+744
View File
@@ -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
}
+49
View File
@@ -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
}
+116
View File
@@ -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)
}
+95
View File
@@ -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)
}
}
}
}
+107
View File
@@ -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
}
+108
View File
@@ -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,
})
}
+538
View File
@@ -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 &current, 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
}
+179
View File
@@ -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 ""
}
}
+36
View File
@@ -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
}
+18
View File
@@ -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,
})
}
+178
View File
@@ -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
}
+38 -1
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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
);