5 Commits
Author SHA1 Message Date
FernandoJVideira 076e616b9c feat: readme 2026-03-18 04:02:11 +00:00
FernandoJVideira 891f3f6ad2 Merge pull request 'feat: integrate music commands with Lavalink support' (#5) from ft/music into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/5
2026-03-18 03:55:06 +00:00
FernandoJVideira ded31409e2 Merge branch 'dev' into ft/music 2026-03-18 03:52:53 +00:00
FernandoJVideira ae3d574c68 Merge pull request 'ft/schedule' (#4) from ft/schedule into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/4
2026-03-18 03:48:14 +00:00
FernandoJVideira d7edfea45e feat: integrate music commands with Lavalink support
- Added new music commands: play, queue, and volume.
- Implemented music management using disgolink for Lavalink integration.
- Updated bot initialization to include Lavalink host and password.
- Enhanced interaction handling for music commands, requiring DJ role for usage.
- Introduced now playing message with interactive buttons for controlling playback.
- Updated dependencies in go.mod for disgolink and snowflake.
2026-03-18 03:47:42 +00:00
18 changed files with 1433 additions and 93 deletions
+97
View File
@@ -0,0 +1,97 @@
# Velox Bot
A Discord bot written in Go with a PostgreSQL backing store.
It currently includes:
- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …)
- Leveling system (opt-in per server via `/slvl toggle`)
- Meeting rooms (voice lobby → auto-created temporary voice channels)
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
- Projects (list/create projects + helpers)
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
## Requirements
- Go (see `go.mod`)
- PostgreSQL 16+ (or use the provided `docker-compose.yml`)
- (Optional) [Air](https://github.com/air-verse/air) for live reload during development
- (Optional) Lavalink if you want music commands
## Configuration
The app loads environment variables from `.env` (see `internal/config/config.go`).
Required:
- `BOT_TOKEN`: Discord bot token
- `APP_ID`: Discord application ID
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
Optional (music):
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
- `LAVALINK_PASSWORD`: Lavalink password
## Database setup
If you use the compose file:
```bash
docker compose up -d
```
Then apply the schema:
```bash
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
```
## Run
Plain Go:
```bash
go run .
```
With live reload (Air):
```bash
air
```
If you use `just`, a small workflow is included:
```bash
just up # start postgres
just start # start postgres + run air
just down # stop postgres
```
## Commands (high-level)
- **Fun**
- `/ping`, `/joke [type]`, `/coinflip`, `/dice <expr>`
- `/rps <hand>`, `/rpsstats`, `/rpsleaderboard` (server only)
- **Help**
- `/help` (sends a DM with command reference)
- **Leveling**
- `/slvl ...` admin/config
- `/rank`, `/leaderboard`, `/rewards`
- **Meetings** (**Manage Server**)
- `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite`
- **Timezone**
- `/timezone set`, `/timezone show`
- **Scheduling**
- `/schedule create`, `/schedule list`, `/schedule reschedule`
- **Projects**
- `/projects list`, `/projects create`, `/projects add-helper`
- **Music** (requires **DJ** role)
- `/play`, `/queue`, `/volume`
## Notes
- Slash commands are registered to the configured `GUILD_ID` on startup.
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
+4 -1
View File
@@ -4,6 +4,8 @@ go 1.26.1
require ( require (
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/disgoorg/disgolink/v3 v3.1.0
github.com/disgoorg/snowflake/v2 v2.0.3
github.com/fogleman/gg v1.3.0 github.com/fogleman/gg v1.3.0
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@@ -11,8 +13,9 @@ require (
) )
require ( require (
github.com/disgoorg/json v1.2.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
+8 -1
View File
@@ -3,12 +3,19 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8=
github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8=
github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y=
github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA=
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+18 -6
View File
@@ -5,6 +5,7 @@ import (
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/events" "velox-bot/internal/events"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -16,9 +17,11 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand
Services *services.Services Services *services.Services
LavalinkHost string
LavalinkPass string
} }
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) { func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass 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
@@ -37,11 +40,13 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
discordgo.IntentsDirectMessageReactions discordgo.IntentsDirectMessageReactions
return &Bot{ return &Bot{
Session: session, Session: session,
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
Commands: cmds, Commands: cmds,
Services: services, Services: services,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil }, nil
} }
@@ -50,6 +55,8 @@ func (b *Bot) Start() error {
return err return err
} }
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
for _, cmd := range b.Commands { for _, cmd := range b.Commands {
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
@@ -68,6 +75,11 @@ func (b *Bot) Start() error {
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
events.HandleVoiceStateUpdate(s, vs, b.Services) events.HandleVoiceStateUpdate(s, vs, b.Services)
music.OnVoiceStateUpdate(vs)
})
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
music.OnVoiceServerUpdate(ev)
}) })
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) { b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
+35 -19
View File
@@ -5,9 +5,11 @@ import (
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level" cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting" "velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/timezone" cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/schedule"
"velox-bot/internal/commands/projects" "velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule"
"velox-bot/internal/commands/timezone"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -25,6 +27,9 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdlevel.Rank, cmdlevel.Rank,
cmdlevel.Leaderboard, cmdlevel.Leaderboard,
cmdlevel.Rewards, cmdlevel.Rewards,
cmdmusic.Play,
cmdmusic.Queue,
cmdmusic.Volume,
meeting.Meeting, meeting.Meeting,
timezone.Timezone, timezone.Timezone,
schedule.Schedule, schedule.Schedule,
@@ -32,26 +37,37 @@ var AllCommands = []*discordgo.ApplicationCommand{
} }
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
"ping": fun.PingHandler, "ping": fun.PingHandler,
"joke": fun.JokeHandler, "joke": fun.JokeHandler,
"coinflip": fun.CoinflipHandler, "coinflip": fun.CoinflipHandler,
"dice": fun.DiceHandler, "dice": fun.DiceHandler,
"rps": fun.RpsHandler, "rps": fun.RpsHandler,
"rpsstats": fun.RpsStatsHandler, "rpsstats": fun.RpsStatsHandler,
"rpsleaderboard": fun.RpsLeaderboardHandler, "rpsleaderboard": fun.RpsLeaderboardHandler,
"help": help.HelpHandler, "help": help.HelpHandler,
"slvl": cmdlevel.SlvlHandler, "slvl": cmdlevel.SlvlHandler,
"rank": cmdlevel.RankHandler, "rank": cmdlevel.RankHandler,
"leaderboard": cmdlevel.LeaderboardHandler, "leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler, "rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler, "meeting": meeting.MeetingHandler,
"timezone": timezone.TimezoneHandler, "timezone": timezone.TimezoneHandler,
"schedule": schedule.ScheduleHandler, "schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler, "projects": projects.ProjectsHandler,
"play": cmdmusic.PlayHandler,
"queue": cmdmusic.QueueHandler,
"volume": cmdmusic.VolumeHandler,
} }
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
if h, ok := handlers[i.ApplicationCommandData().Name]; ok { switch i.Type {
h(s, i) case discordgo.InteractionApplicationCommand:
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
}
case discordgo.InteractionMessageComponent:
data := i.MessageComponentData()
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
music.HandleComponent(s, i)
}
} }
} }
+50 -57
View File
@@ -42,80 +42,73 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
embed := &discordgo.MessageEmbed{ embed := &discordgo.MessageEmbed{
Title: "Velox Bot Help", Title: "Velox Bot Help",
Description: "Here is the help for the bot.", Description: "Command reference. Some commands require server permissions or roles.",
Color: 0xFFA500, Color: 0xFFA500,
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
{ {
Name: "Fun Commands", Name: "Fun",
Value: "Small utility + games.", Value: "" +
"`/ping` — Pong!\n" +
"`/joke [type]` — Random joke (optional category)\n" +
"`/coinflip` — Flip a coin\n" +
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
"`/rpsstats` — Your RPS score (server only)\n" +
"`/rpsleaderboard` — Top RPS scores (server only)",
}, },
{ {
Name: "/ping", Name: "Music (requires **DJ** role)",
Value: "Pong!", Value: "" +
"`/play <query>` — Play a song (search or URL)\n" +
"`/queue` — Show the current queue\n" +
"`/volume <0-150>` — Set playback volume",
}, },
{ {
Name: "/joke [type]", Name: "Scheduling (1:1 sessions)",
Value: "Get a random joke (optional category).", Value: "" +
"`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" +
"`/schedule list` — List your upcoming sessions\n" +
"`/schedule reschedule id:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
}, },
{ {
Name: "/coinflip", Name: "Timezone",
Value: "Flip a coin.", Value: "" +
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
"`/timezone show` — Show your timezone",
}, },
{ {
Name: "/dice <expr>", Name: "Projects",
Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).", Value: "" +
"`/projects list` — List active projects\n" +
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
}, },
{ {
Name: "/rps <hand>", Name: "Meetings (**Manage Server**)",
Value: "Play Rock Paper Scissors (server only).", Value: "" +
"`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" +
"`/meeting lock` — Lock your meeting room\n" +
"`/meeting lock-private` — Lock + hide your meeting room\n" +
"`/meeting unlock` — Unlock your meeting room\n" +
"`/meeting invite user:@someone` — Allow a user to join\n" +
"`/meeting uninvite user:@someone` — Remove a user's permission",
}, },
{ {
Name: "/rpsstats", Name: "Leveling",
Value: "Show your RPS score (server only).", Value: "" +
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
"`/rank [user]` — Your rank card (server only)\n" +
"`/leaderboard` — Top levels (server only)\n" +
"`/rewards` — List configured rewards (server only)",
}, },
{ {
Name: "/rpsleaderboard", Name: "Other",
Value: "Show top RPS scores (server only).", Value: "`/help` — Show this help in DMs",
},
{
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, Author: &author,
@@ -0,0 +1,36 @@
package public
import "github.com/bwmarrin/discordgo"
func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
if s == nil || guildID == "" || m == nil {
return false
}
if len(m.Roles) == 0 {
return false
}
roles, err := s.GuildRoles(guildID)
if err != nil {
return false
}
djRoleID := ""
for _, r := range roles {
if r != nil && r.Name == "DJ" {
djRoleID = r.ID
break
}
}
if djRoleID == "" {
return false
}
for _, rid := range m.Roles {
if rid == djRoleID {
return true
}
}
return false
}
+130
View File
@@ -0,0 +1,130 @@
package public
import (
"fmt"
"net/url"
"strings"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
)
var Play = &discordgo.ApplicationCommand{
Name: "play",
Description: "Play a song via Lavalink",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "query",
Description: "Song name or URL",
Required: true,
},
},
}
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
query := data.Options[0].StringValue()
// Always acknowledge quickly to avoid "application did not respond".
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Searching...",
},
})
if !memberHasDJRole(s, i.GuildID, i.Member) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("You need the **DJ** role to use music commands."),
})
return
}
// If user provided plain text, turn it into a YouTube search
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
query = "ytsearch:" + query
} else {
query = normalizeYouTubeRadioURL(query)
}
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)),
})
return
}
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)),
})
return
}
title := entry.Track.Info.Title
author := entry.Track.Info.Author
length := entry.Track.Info.Length
if started {
// manager will post now-playing message & manage invalidating old buttons
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
})
} else {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
})
}
}
func ptr[T any](v T) *T { return new(v) }
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
// It intentionally does NOT strip normal playlist URLs.
func normalizeYouTubeRadioURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u == nil {
return raw
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
return raw
}
q := u.Query()
v := q.Get("v")
if v == "" {
return raw
}
list := q.Get("list")
_, hasStartRadio := q["start_radio"]
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
return raw
}
u.RawQuery = url.Values{"v": []string{v}}.Encode()
u.Fragment = ""
return u.String()
}
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
g, err := s.State.Guild(guildID)
if err != nil {
return nil, fmt.Errorf("cannot find guild voice state")
}
for _, vs := range g.VoiceStates {
if vs.UserID == userID {
return vs, nil
}
}
return nil, fmt.Errorf("you must be in a voice channel")
}
+77
View File
@@ -0,0 +1,77 @@
package public
import (
"fmt"
"strings"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
)
var Queue = &discordgo.ApplicationCommand{
Name: "queue",
Description: "Show the current music queue",
}
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !memberHasDJRole(s, i.GuildID, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You need the **DJ** role to use music commands.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
current, rest := music.GetQueue(i.GuildID)
if current == nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "The queue is currently empty.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author)
var descBuilder strings.Builder
if len(rest) > 0 {
limit := len(rest)
if limit > 10 {
limit = 10
}
for idx, entry := range rest[:limit] {
fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy)
}
if len(rest) > limit {
fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit)
}
} else {
descBuilder.WriteString("Nothing else in the queue.")
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Title: "Music Queue",
Description: descBuilder.String(),
Fields: []*discordgo.MessageEmbedField{
{
Name: "Now Playing",
Value: nowPlaying,
},
},
},
},
},
})
}
+63
View File
@@ -0,0 +1,63 @@
package public
import (
"fmt"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
)
var Volume = &discordgo.ApplicationCommand{
Name: "volume",
Description: "Set playback volume (0-150)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "value",
Description: "Volume percent (0-150)",
Required: true,
MinValue: ptrFloat(0),
MaxValue: 150,
},
},
}
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Updating volume...",
Flags: discordgo.MessageFlagsEphemeral,
},
})
if !memberHasDJRole(s, i.GuildID, i.Member) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("You need the **DJ** role to use music commands."),
})
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("Missing volume value."),
})
return
}
vol := int(data.Options[0].IntValue())
if err := music.SetVolume(i.GuildID, vol); err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)),
})
return
}
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)),
})
}
func ptrFloat(v float64) *float64 { return &v }
+20
View File
@@ -0,0 +1,20 @@
package music
import (
"velox-bot/internal/commands/music/public"
"github.com/bwmarrin/discordgo"
)
var (
Play *discordgo.ApplicationCommand = public.Play
Queue *discordgo.ApplicationCommand = public.Queue
Volume *discordgo.ApplicationCommand = public.Volume
)
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.QueueHandler(s, i)
}
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
+15 -8
View File
@@ -8,10 +8,12 @@ import (
) )
type Config struct { type Config struct {
BotToken string BotToken string
AppID string AppID string
GuildID string GuildID string
DBHost string DBHost string
LavalinkHost string
LavalinkPass string
} }
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
@@ -39,10 +41,15 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("DB_HOST is not set") return nil, fmt.Errorf("DB_HOST is not set")
} }
lavalinkHost := os.Getenv("LAVALINK_HOST")
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
return &Config{ return &Config{
BotToken: token, BotToken: token,
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
DBHost: dbHost, DBHost: dbHost,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil }, nil
} }
+108
View File
@@ -0,0 +1,108 @@
package music
import (
"strings"
"github.com/bwmarrin/discordgo"
)
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
// DJ role is required for all music controls.
if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You need the **DJ** role to use music commands.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
customID := i.MessageComponentData().CustomID
parts := strings.Split(customID, ":")
if len(parts) < 2 {
return
}
action := parts[1]
guildID := i.GuildID
// Ignore controls from old now-playing messages
currentMsgID := CurrentNowPlayingMessageID(guildID)
if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "These controls are outdated.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Acknowledge immediately to avoid "This interaction failed".
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredMessageUpdate,
})
var err error
content := ""
switch action {
case "pause":
err = Pause(guildID, true)
content = "Paused."
case "resume":
err = Pause(guildID, false)
content = "Resumed."
case "toggle_pause":
paused, _ := pausedAndVolume(guildID)
err = Pause(guildID, !paused)
if paused {
content = "Resumed."
} else {
content = "Paused."
}
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
case "skip":
err = Skip(guildID)
content = "Skipped."
case "stop":
err = Stop(guildID)
content = "Stopped."
case "repeat_song":
rs, rq := ToggleRepeatSong(guildID)
_ = rs
_ = rq
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
if rs {
content = "Repeat song enabled."
} else {
content = "Repeat song disabled."
}
case "repeat_queue":
rs, rq := ToggleRepeatQueue(guildID)
_ = rs
_ = rq
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
if rq {
content = "Repeat queue enabled."
} else {
content = "Repeat queue disabled."
}
}
if content == "" {
content = "Done."
}
if err != nil {
content = "Error: " + err.Error()
}
// send ephemeral confirmation
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
Content: content,
Flags: discordgo.MessageFlagsEphemeral,
})
}
+538
View File
@@ -0,0 +1,538 @@
package music
import (
"context"
"fmt"
"net/url"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink"
"github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2"
)
type TrackEntry struct {
Track lavalink.Track
RequestedBy string
}
type guildPlayer struct {
Player disgolink.Player
Queue []TrackEntry
RepeatSong bool
RepeatQueue bool
Paused bool
Volume int
IdleSince time.Time
TextChannelID string
NowPlayingMsgID string
}
type Manager struct {
client disgolink.Client
session *discordgo.Session
mu sync.Mutex
players map[string]*guildPlayer
}
var manager *Manager
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
if lavalinkHost == "" {
return nil
}
userID, err := snowflake.Parse(appID)
if err != nil {
return fmt.Errorf("parse app id for lavalink: %w", err)
}
m := &Manager{
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
session: session,
players: make(map[string]*guildPlayer),
}
u, err := url.Parse(lavalinkHost)
if err != nil {
return fmt.Errorf("parse lavalink host: %w", err)
}
secure := u.Scheme == "https" || u.Scheme == "wss"
password := lavalinkPass
if password == "" {
password = "youshallnotpass"
}
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
Name: "main",
Address: u.Host,
Password: password,
Secure: secure,
})
if err != nil {
return fmt.Errorf("add lavalink node: %w", err)
}
manager = m
go idleDisconnectLoop()
return nil
}
func idleDisconnectLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if manager == nil || manager.session == nil {
continue
}
now := time.Now()
var toDisconnect []string
manager.mu.Lock()
for guildID, gp := range manager.players {
if gp == nil {
continue
}
// If we're paused or have anything queued/playing, clear idle timer.
if gp.Paused || len(gp.Queue) > 0 {
gp.IdleSince = time.Time{}
continue
}
if gp.IdleSince.IsZero() {
gp.IdleSince = now
continue
}
if now.Sub(gp.IdleSince) >= time.Minute {
toDisconnect = append(toDisconnect, guildID)
gp.IdleSince = time.Time{}
}
}
manager.mu.Unlock()
for _, guildID := range toDisconnect {
// Best-effort: disconnect. Safe even if we're already disconnected.
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
}
}
}
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
if manager == nil {
return
}
if vs.UserID != manager.session.State.User.ID {
return
}
guildID := snowflake.MustParse(vs.GuildID)
var channelID *snowflake.ID
if vs.ChannelID != "" {
id := snowflake.MustParse(vs.ChannelID)
channelID = &id
}
manager.client.OnVoiceStateUpdate(
context.Background(),
guildID,
channelID,
vs.SessionID,
)
}
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
if manager == nil {
return
}
endpoint := ""
if ev.Endpoint != "" {
endpoint = ev.Endpoint
}
manager.client.OnVoiceServerUpdate(
context.Background(),
snowflake.MustParse(ev.GuildID),
ev.Token,
endpoint,
)
}
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
manager.mu.Lock()
defer manager.mu.Unlock()
if gp, ok := manager.players[guildID]; ok {
return gp
}
player := manager.client.Player(snowflake.MustParse(guildID))
gp := &guildPlayer{
Player: player,
Queue: make([]TrackEntry, 0),
Volume: 100,
}
manager.players[guildID] = gp
return gp
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized")
}
// Join voice channel
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
return nil, false, fmt.Errorf("join voice channel: %w", err)
}
gp := getOrCreateGuildPlayer(guildID)
if textChannelID != "" {
manager.mu.Lock()
gp.TextChannelID = textChannelID
manager.mu.Unlock()
}
var loadedTracks []lavalink.Track
manager.client.BestNode().LoadTracksHandler(
context.Background(),
query,
disgolink.NewResultHandler(
func(track lavalink.Track) {
loadedTracks = append(loadedTracks, track)
},
func(playlist lavalink.Playlist) {
if len(playlist.Tracks) == 0 {
return
}
// Respect Lavalink's selectedTrack when present by rotating the playlist.
selected := playlist.Info.SelectedTrack
if selected >= 0 && selected < len(playlist.Tracks) {
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
return
}
loadedTracks = append(loadedTracks, playlist.Tracks...)
},
func(tracks []lavalink.Track) {
if len(tracks) > 0 {
loadedTracks = append(loadedTracks, tracks[0])
}
},
func() {
},
func(err error) {
},
),
)
if len(loadedTracks) == 0 {
return nil, false, fmt.Errorf("no tracks found for query")
}
// Some sources may return unplayable entries; pick the first with an encoded track.
firstPlayableIdx := 0
for idx := range loadedTracks {
if loadedTracks[idx].Encoded != "" {
firstPlayableIdx = idx
break
}
}
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
}
entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks {
entries[idx] = TrackEntry{
Track: t,
RequestedBy: requester,
}
}
firstEntry := entries[0]
manager.mu.Lock()
shouldStart := len(gp.Queue) == 0
gp.Queue = append(gp.Queue, entries...)
gp.IdleSince = time.Time{}
manager.mu.Unlock()
if shouldStart {
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
return nil, false, fmt.Errorf("start track: %w", err)
}
if gp.TextChannelID != "" {
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
}
}
return &firstEntry, shouldStart, nil
}
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if manager == nil {
return
}
if !event.Reason.MayStartNext() {
return
}
guildID := player.GuildID().String()
manager.mu.Lock()
gp, ok := manager.players[guildID]
if !ok || len(gp.Queue) == 0 {
manager.mu.Unlock()
return
}
// repeat current track
if gp.RepeatSong {
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
return
}
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
return
}
// pop current and optionally cycle it to end
finished := gp.Queue[0]
gp.Queue = gp.Queue[1:]
if gp.RepeatQueue {
gp.Queue = append(gp.Queue, finished)
}
if len(gp.Queue) == 0 {
textChannelID := gp.TextChannelID
nowPlayingMsgID := gp.NowPlayingMsgID
gp.NowPlayingMsgID = ""
gp.IdleSince = time.Now()
manager.mu.Unlock()
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
return
}
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
return
}
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
}
func Pause(guildID string, pause bool) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
gp.Paused = pause
if pause {
gp.IdleSince = time.Time{}
}
manager.mu.Unlock()
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
}
func Skip(guildID string) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
if len(gp.Queue) == 0 {
manager.mu.Unlock()
return nil
}
// skip always advances regardless of repeat song
skipped := gp.Queue[0]
gp.Queue = gp.Queue[1:]
if gp.RepeatQueue {
gp.Queue = append(gp.Queue, skipped)
}
if len(gp.Queue) == 0 {
textChannelID := gp.TextChannelID
nowPlayingMsgID := gp.NowPlayingMsgID
gp.NowPlayingMsgID = ""
gp.IdleSince = time.Now()
manager.mu.Unlock()
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
return err
}
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
return nil
}
func Stop(guildID string) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
textChannelID := gp.TextChannelID
nowPlayingMsgID := gp.NowPlayingMsgID
gp.Queue = nil
gp.Paused = false
gp.NowPlayingMsgID = ""
gp.IdleSince = time.Now()
manager.mu.Unlock()
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
if manager == nil {
return nil, nil
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
if len(gp.Queue) == 0 {
return nil, nil
}
current := gp.Queue[0]
rest := make([]TrackEntry, len(gp.Queue)-1)
copy(rest, gp.Queue[1:])
return &current, rest
}
func ToggleRepeatSong(guildID string) (bool, bool) {
if manager == nil {
return false, false
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatSong = !gp.RepeatSong
if gp.RepeatSong {
gp.RepeatQueue = false
}
return gp.RepeatSong, gp.RepeatQueue
}
func ToggleRepeatQueue(guildID string) (bool, bool) {
if manager == nil {
return false, false
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatQueue = !gp.RepeatQueue
if gp.RepeatQueue {
gp.RepeatSong = false
}
return gp.RepeatSong, gp.RepeatQueue
}
func repeatFlags(guildID string) (bool, bool) {
if manager == nil {
return false, false
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
return gp.RepeatSong, gp.RepeatQueue
}
func pausedAndVolume(guildID string) (bool, int) {
if manager == nil {
return false, 100
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
vol := gp.Volume
if vol <= 0 {
vol = 100
}
return gp.Paused, vol
}
func SetVolume(guildID string, volume int) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
if volume < 0 {
volume = 0
}
if volume > 150 {
volume = 150
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
gp.Volume = volume
textChannelID := gp.TextChannelID
nowPlayingMsgID := gp.NowPlayingMsgID
manager.mu.Unlock()
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
return err
}
// Refresh the embed so the displayed volume matches.
if textChannelID != "" && nowPlayingMsgID != "" {
current, _ := GetQueue(guildID)
if current != nil {
postNowPlaying(guildID, textChannelID, *current)
}
}
return nil
}
func CurrentNowPlayingMessageID(guildID string) string {
if manager == nil {
return ""
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
return gp.NowPlayingMsgID
}
+179
View File
@@ -0,0 +1,179 @@
package music
import (
"fmt"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2"
)
func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent {
repeatSongStyle := discordgo.SecondaryButton
if repeatSong {
repeatSongStyle = discordgo.SuccessButton
}
repeatQueueStyle := discordgo.SecondaryButton
if repeatQueue {
repeatQueueStyle = discordgo.SuccessButton
}
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Style: discordgo.SecondaryButton,
Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]},
CustomID: "music:toggle_pause",
Disabled: disabled,
},
discordgo.Button{
Style: discordgo.SecondaryButton,
Emoji: &discordgo.ComponentEmoji{Name: "⏭️"},
CustomID: "music:skip",
Disabled: disabled,
},
discordgo.Button{
Style: repeatSongStyle,
Emoji: &discordgo.ComponentEmoji{Name: "🔂"},
CustomID: "music:repeat_song",
Disabled: disabled,
},
discordgo.Button{
Style: repeatQueueStyle,
Emoji: &discordgo.ComponentEmoji{Name: "🔁"},
CustomID: "music:repeat_queue",
Disabled: disabled,
},
discordgo.Button{
Style: discordgo.DangerButton,
Emoji: &discordgo.ComponentEmoji{Name: "✖️"},
CustomID: "music:stop",
Disabled: disabled,
},
},
},
}
}
func disableNowPlaying(textChannelID, messageID string) {
if manager == nil || textChannelID == "" || messageID == "" {
return
}
disabled := nowPlayingComponents(true, false, false, false)
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: textChannelID,
ID: messageID,
Components: &disabled,
})
}
func postNowPlaying(guildID, textChannelID string, entry TrackEntry) {
if manager == nil || textChannelID == "" {
return
}
manager.mu.Lock()
gp, ok := manager.players[guildID]
var oldMsgID string
if ok {
oldMsgID = gp.NowPlayingMsgID
}
manager.mu.Unlock()
if oldMsgID != "" {
disableNowPlaying(textChannelID, oldMsgID)
}
repeatSong, repeatQueue := repeatFlags(guildID)
paused, volume := pausedAndVolume(guildID)
info := entry.Track.Info
title := info.Title
embed := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{
Name: info.Author,
IconURL: sourceIconURL(info.SourceName),
},
Title: title,
Color: 0x2B2D31, // matches Discord dark-ish embed accent
Fields: []*discordgo.MessageEmbedField{
{Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true},
{Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true},
{Name: "Requested by", Value: entry.RequestedBy, Inline: true},
},
}
if info.URI != nil && *info.URI != "" {
embed.URL = *info.URI
}
if art := artworkURL(info); art != "" {
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art}
}
msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused),
})
if err != nil {
return
}
manager.mu.Lock()
// Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks).
gp = manager.players[guildID]
if gp == nil {
gp = &guildPlayer{
Player: manager.client.Player(snowflake.MustParse(guildID)),
Queue: make([]TrackEntry, 0),
}
manager.players[guildID] = gp
}
gp.TextChannelID = textChannelID
gp.NowPlayingMsgID = msg.ID
manager.mu.Unlock()
}
func artworkURL(info lavalink.TrackInfo) string {
if info.ArtworkURL != nil && *info.ArtworkURL != "" {
return *info.ArtworkURL
}
// Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback.
if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" {
return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg"
}
return ""
}
func formatDuration(d lavalink.Duration, isStream bool) string {
if isStream {
return "Live"
}
secs := d.Seconds()
if secs < 0 {
secs = 0
}
h := secs / 3600
m := (secs % 3600) / 60
s := secs % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func sourceIconURL(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "youtube":
// Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds)
return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com"
case "soundcloud":
return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com"
default:
return ""
}
}
+36
View File
@@ -0,0 +1,36 @@
package music
import "github.com/bwmarrin/discordgo"
func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
if s == nil || guildID == "" || m == nil {
return false
}
if len(m.Roles) == 0 {
return false
}
roles, err := s.GuildRoles(guildID)
if err != nil {
return false
}
djRoleID := ""
for _, r := range roles {
if r != nil && r.Name == "DJ" {
djRoleID = r.ID
break
}
}
if djRoleID == "" {
return false
}
for _, rid := range m.Roles {
if rid == djRoleID {
return true
}
}
return false
}
+18
View File
@@ -0,0 +1,18 @@
package music
import "github.com/bwmarrin/discordgo"
func updateNowPlayingButtons(channelID, messageID string, guildID string) {
if manager == nil || channelID == "" || messageID == "" {
return
}
repeatSong, repeatQueue := repeatFlags(guildID)
paused, _ := pausedAndVolume(guildID)
comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused)
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: channelID,
ID: messageID,
Components: &comps,
})
}
+1 -1
View File
@@ -55,7 +55,7 @@ func main() {
rpsService := rps.New(rpsRepo) rpsService := rps.New(rpsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService) services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, 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