Merge pull request 'ft/lvl_system' (#1) from ft/lvl_system into dev

Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-03-17 16:32:52 +00:00
33 changed files with 1824 additions and 8 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 .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 ( require (
github.com/bwmarrin/discordgo v0.29.0 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 github.com/joho/godotenv v1.5.1
golang.org/x/image v0.37.0
) )
require ( require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/websocket v1.4.2 // 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/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/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 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 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/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 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/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.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= 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=
+10 -2
View File
@@ -3,6 +3,8 @@ package bot
import ( import (
"log" "log"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -13,9 +15,10 @@ type Bot struct {
GuildID string GuildID string
Commands []*discordgo.ApplicationCommand Commands []*discordgo.ApplicationCommand
registeredCommands []*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) session, err := discordgo.New("Bot " + token)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -26,6 +29,7 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand)
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
Commands: cmds, Commands: cmds,
Services: services,
}, nil }, nil
} }
@@ -45,8 +49,12 @@ func (b *Bot) Start() error {
} }
b.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(commands.HandleInteraction)
return nil
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
events.HandleMessageCreate(s, m, b.Services)
})
return nil
} }
func (b *Bot) Close() error { func (b *Bot) Close() error {
+14 -2
View File
@@ -2,16 +2,28 @@ package commands
import ( import (
"velox-bot/internal/commands/fun" "velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
var AllCommands = []*discordgo.ApplicationCommand{ var AllCommands = []*discordgo.ApplicationCommand{
fun.PingCommand, fun.Ping,
help.Help,
cmdlevel.Slvl,
cmdlevel.Rank,
cmdlevel.Leaderboard,
cmdlevel.Rewards,
} }
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
"ping": fun.HandlePing, "ping": fun.PingHandler,
"help": help.HelpHandler,
"slvl": cmdlevel.SlvlHandler,
"rank": cmdlevel.RankHandler,
"leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler,
} }
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
+2 -2
View File
@@ -2,12 +2,12 @@ package fun
import "github.com/bwmarrin/discordgo" import "github.com/bwmarrin/discordgo"
var PingCommand = &discordgo.ApplicationCommand{ var Ping = &discordgo.ApplicationCommand{
Name: "ping", Name: "ping",
Description: "Ping the bot", 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{ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
+77
View File
@@ -0,0 +1,77 @@
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: "Here are the fun commands for the bot.",
},
{
Name: "/ping",
Value: "Pong!",
},
{
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.",
},
},
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},
},
})
}
+23
View File
@@ -0,0 +1,23 @@
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
}
+7
View File
@@ -11,6 +11,7 @@ type Config struct {
BotToken string BotToken string
AppID string AppID string
GuildID string GuildID string
DBHost string
} }
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
@@ -33,9 +34,15 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("GUILD_ID is not set") 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{ return &Config{
BotToken: token, BotToken: token,
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
DBHost: dbHost,
}, nil }, 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
}
+172
View File
@@ -0,0 +1,172 @@
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) 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)
}
+22
View File
@@ -0,0 +1,22 @@
package services
import (
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
)
type Services struct {
Level *level.Service
LevelSettings *levelsettings.Service
}
var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service) *Services {
s := &Services{
Level: level,
LevelSettings: levelSettings,
}
Global = s
return s
}
+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
}
+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
}
+20 -1
View File
@@ -9,6 +9,12 @@ import (
"velox-bot/internal/bot" "velox-bot/internal/bot"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/config" "velox-bot/internal/config"
"velox-bot/internal/db"
"velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
) )
func main() { func main() {
@@ -18,7 +24,20 @@ func main() {
return 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)
levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
if err != nil { if err != nil {
log.Fatalf("Error creating bot: %v", err) log.Fatalf("Error creating bot: %v", err)
return 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;
+71
View File
@@ -0,0 +1,71 @@
-- 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
);
--