12 Commits
54 changed files with 4640 additions and 9 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:
+9
View File
@@ -4,11 +4,20 @@ go 1.26.1
require (
github.com/bwmarrin/discordgo v0.29.0
github.com/fogleman/gg v1.3.0
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
golang.org/x/image v0.37.0
)
require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
golang.org/x/text v0.35.0 // indirect
)
+32
View File
@@ -1,14 +1,46 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+20 -2
View File
@@ -3,6 +3,8 @@ package bot
import (
"log"
"velox-bot/internal/commands"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
"github.com/bwmarrin/discordgo"
)
@@ -13,19 +15,27 @@ type Bot struct {
GuildID string
Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand
Services *services.Services
}
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand) (*Bot, error) {
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, err
}
// Intents needed for:
// - InteractionCreate (slash commands): Guilds
// - MessageCreate (leveling): GuildMessages
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates
return &Bot{
Session: session,
AppID: appID,
GuildID: guildID,
Commands: cmds,
Services: services,
}, nil
}
@@ -45,8 +55,16 @@ func (b *Bot) Start() error {
}
b.Session.AddHandler(commands.HandleInteraction)
return nil
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
events.HandleMessageCreate(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
events.HandleVoiceStateUpdate(s, vs, b.Services)
})
return nil
}
func (b *Bot) Close() error {
+32 -2
View File
@@ -2,16 +2,46 @@ package commands
import (
"velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/projects"
"github.com/bwmarrin/discordgo"
)
var AllCommands = []*discordgo.ApplicationCommand{
fun.PingCommand,
fun.Ping,
fun.Joke,
fun.Coinflip,
fun.Dice,
fun.Rps,
fun.RpsStats,
fun.RpsLeaderboard,
help.Help,
cmdlevel.Slvl,
cmdlevel.Rank,
cmdlevel.Leaderboard,
cmdlevel.Rewards,
meeting.Meeting,
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,
"projects": projects.ProjectsHandler,
}
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
+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,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
}
+7
View File
@@ -11,6 +11,7 @@ type Config struct {
BotToken string
AppID string
GuildID string
DBHost string
}
func LoadConfig() (*Config, error) {
@@ -33,9 +34,15 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("GUILD_ID is not set")
}
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
return nil, fmt.Errorf("DB_HOST is not set")
}
return &Config{
BotToken: token,
AppID: appID,
GuildID: guildID,
DBHost: dbHost,
}, nil
}
+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
}
+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
}
}
+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")
+31
View File
@@ -0,0 +1,31 @@
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/projects"
"velox-bot/internal/db/services/rps"
)
type Services struct {
Level *level.Service
LevelSettings *levelsettings.Service
Meeting *meeting.Service
Projects *projects.Service
RPS *rps.Service
}
var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, projects *projects.Service, rps *rps.Service) *Services {
s := &Services{
Level: level,
LevelSettings: levelSettings,
Meeting: meeting,
Projects: projects,
RPS: rps,
}
Global = s
return s
}
+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)
}
+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
}
+30 -1
View File
@@ -9,6 +9,17 @@ 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/settingsrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
)
func main() {
@@ -18,7 +29,25 @@ 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)
levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo)
projectsService := projects.New(projectsRepo)
rpsService := rps.New(rpsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, projectsService, rpsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
if err != nil {
log.Fatalf("Error creating bot: %v", err)
return
+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;
+103
View File
@@ -0,0 +1,103 @@
-- 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)
);
--