Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076e616b9c | ||
|
|
891f3f6ad2 | ||
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
d7edfea45e |
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Play = &discordgo.ApplicationCommand{
|
||||||
|
Name: "play",
|
||||||
|
Description: "Play a song via Lavalink",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "query",
|
||||||
|
Description: "Song name or URL",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := data.Options[0].StringValue()
|
||||||
|
|
||||||
|
// Always acknowledge quickly to avoid "application did not respond".
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Searching...",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("You need the **DJ** role to use music commands."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user provided plain text, turn it into a YouTube search
|
||||||
|
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
|
||||||
|
query = "ytsearch:" + query
|
||||||
|
} else {
|
||||||
|
query = normalizeYouTubeRadioURL(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := entry.Track.Info.Title
|
||||||
|
author := entry.Track.Info.Author
|
||||||
|
length := entry.Track.Info.Length
|
||||||
|
|
||||||
|
if started {
|
||||||
|
// manager will post now-playing message & manage invalidating old buttons
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return new(v) }
|
||||||
|
|
||||||
|
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
|
||||||
|
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
|
||||||
|
// It intentionally does NOT strip normal playlist URLs.
|
||||||
|
func normalizeYouTubeRadioURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(u.Host)
|
||||||
|
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
v := q.Get("v")
|
||||||
|
if v == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
list := q.Get("list")
|
||||||
|
_, hasStartRadio := q["start_radio"]
|
||||||
|
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
u.RawQuery = url.Values{"v": []string{v}}.Encode()
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||||
|
g, err := s.State.Guild(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot find guild voice state")
|
||||||
|
}
|
||||||
|
for _, vs := range g.VoiceStates {
|
||||||
|
if vs.UserID == userID {
|
||||||
|
return vs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("you must be in a voice channel")
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Queue = &discordgo.ApplicationCommand{
|
||||||
|
Name: "queue",
|
||||||
|
Description: "Show the current music queue",
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You need the **DJ** role to use music commands.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, rest := music.GetQueue(i.GuildID)
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "The queue is currently empty.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author)
|
||||||
|
|
||||||
|
var descBuilder strings.Builder
|
||||||
|
if len(rest) > 0 {
|
||||||
|
limit := len(rest)
|
||||||
|
if limit > 10 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
for idx, entry := range rest[:limit] {
|
||||||
|
fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy)
|
||||||
|
}
|
||||||
|
if len(rest) > limit {
|
||||||
|
fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
descBuilder.WriteString("Nothing else in the queue.")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{
|
||||||
|
{
|
||||||
|
Title: "Music Queue",
|
||||||
|
Description: descBuilder.String(),
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{
|
||||||
|
Name: "Now Playing",
|
||||||
|
Value: nowPlaying,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Volume = &discordgo.ApplicationCommand{
|
||||||
|
Name: "volume",
|
||||||
|
Description: "Set playback volume (0-150)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionInteger,
|
||||||
|
Name: "value",
|
||||||
|
Description: "Volume percent (0-150)",
|
||||||
|
Required: true,
|
||||||
|
MinValue: ptrFloat(0),
|
||||||
|
MaxValue: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Updating volume...",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("You need the **DJ** role to use music commands."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("Missing volume value."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vol := int(data.Options[0].IntValue())
|
||||||
|
if err := music.SetVolume(i.GuildID, vol); err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrFloat(v float64) *float64 { return &v }
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/music/public"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Play *discordgo.ApplicationCommand = public.Play
|
||||||
|
Queue *discordgo.ApplicationCommand = public.Queue
|
||||||
|
Volume *discordgo.ApplicationCommand = public.Volume
|
||||||
|
)
|
||||||
|
|
||||||
|
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
|
||||||
|
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.QueueHandler(s, i)
|
||||||
|
}
|
||||||
|
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
// DJ role is required for all music controls.
|
||||||
|
if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You need the **DJ** role to use music commands.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customID := i.MessageComponentData().CustomID
|
||||||
|
parts := strings.Split(customID, ":")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := parts[1]
|
||||||
|
guildID := i.GuildID
|
||||||
|
|
||||||
|
// Ignore controls from old now-playing messages
|
||||||
|
currentMsgID := CurrentNowPlayingMessageID(guildID)
|
||||||
|
if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "These controls are outdated.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge immediately to avoid "This interaction failed".
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredMessageUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
var err error
|
||||||
|
content := ""
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "pause":
|
||||||
|
err = Pause(guildID, true)
|
||||||
|
content = "Paused."
|
||||||
|
case "resume":
|
||||||
|
err = Pause(guildID, false)
|
||||||
|
content = "Resumed."
|
||||||
|
case "toggle_pause":
|
||||||
|
paused, _ := pausedAndVolume(guildID)
|
||||||
|
err = Pause(guildID, !paused)
|
||||||
|
if paused {
|
||||||
|
content = "Resumed."
|
||||||
|
} else {
|
||||||
|
content = "Paused."
|
||||||
|
}
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
case "skip":
|
||||||
|
err = Skip(guildID)
|
||||||
|
content = "Skipped."
|
||||||
|
case "stop":
|
||||||
|
err = Stop(guildID)
|
||||||
|
content = "Stopped."
|
||||||
|
case "repeat_song":
|
||||||
|
rs, rq := ToggleRepeatSong(guildID)
|
||||||
|
_ = rs
|
||||||
|
_ = rq
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
if rs {
|
||||||
|
content = "Repeat song enabled."
|
||||||
|
} else {
|
||||||
|
content = "Repeat song disabled."
|
||||||
|
}
|
||||||
|
case "repeat_queue":
|
||||||
|
rs, rq := ToggleRepeatQueue(guildID)
|
||||||
|
_ = rs
|
||||||
|
_ = rq
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
if rq {
|
||||||
|
content = "Repeat queue enabled."
|
||||||
|
} else {
|
||||||
|
content = "Repeat queue disabled."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
content = "Done."
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
content = "Error: " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// send ephemeral confirmation
|
||||||
|
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||||
|
Content: content,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||||
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackEntry struct {
|
||||||
|
Track lavalink.Track
|
||||||
|
RequestedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildPlayer struct {
|
||||||
|
Player disgolink.Player
|
||||||
|
Queue []TrackEntry
|
||||||
|
RepeatSong bool
|
||||||
|
RepeatQueue bool
|
||||||
|
Paused bool
|
||||||
|
Volume int
|
||||||
|
IdleSince time.Time
|
||||||
|
TextChannelID string
|
||||||
|
NowPlayingMsgID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
client disgolink.Client
|
||||||
|
session *discordgo.Session
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
players map[string]*guildPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager *Manager
|
||||||
|
|
||||||
|
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
|
||||||
|
if lavalinkHost == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := snowflake.Parse(appID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse app id for lavalink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
||||||
|
session: session,
|
||||||
|
players: make(map[string]*guildPlayer),
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(lavalinkHost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse lavalink host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secure := u.Scheme == "https" || u.Scheme == "wss"
|
||||||
|
password := lavalinkPass
|
||||||
|
if password == "" {
|
||||||
|
password = "youshallnotpass"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||||
|
Name: "main",
|
||||||
|
Address: u.Host,
|
||||||
|
Password: password,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add lavalink node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = m
|
||||||
|
go idleDisconnectLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func idleDisconnectLoop() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if manager == nil || manager.session == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var toDisconnect []string
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
for guildID, gp := range manager.players {
|
||||||
|
if gp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're paused or have anything queued/playing, clear idle timer.
|
||||||
|
if gp.Paused || len(gp.Queue) > 0 {
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if gp.IdleSince.IsZero() {
|
||||||
|
gp.IdleSince = now
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(gp.IdleSince) >= time.Minute {
|
||||||
|
toDisconnect = append(toDisconnect, guildID)
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
for _, guildID := range toDisconnect {
|
||||||
|
// Best-effort: disconnect. Safe even if we're already disconnected.
|
||||||
|
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vs.UserID != manager.session.State.User.ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID := snowflake.MustParse(vs.GuildID)
|
||||||
|
var channelID *snowflake.ID
|
||||||
|
if vs.ChannelID != "" {
|
||||||
|
id := snowflake.MustParse(vs.ChannelID)
|
||||||
|
channelID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.client.OnVoiceStateUpdate(
|
||||||
|
context.Background(),
|
||||||
|
guildID,
|
||||||
|
channelID,
|
||||||
|
vs.SessionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endpoint := ""
|
||||||
|
if ev.Endpoint != "" {
|
||||||
|
endpoint = ev.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.client.OnVoiceServerUpdate(
|
||||||
|
context.Background(),
|
||||||
|
snowflake.MustParse(ev.GuildID),
|
||||||
|
ev.Token,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
|
||||||
|
if gp, ok := manager.players[guildID]; ok {
|
||||||
|
return gp
|
||||||
|
}
|
||||||
|
|
||||||
|
player := manager.client.Player(snowflake.MustParse(guildID))
|
||||||
|
gp := &guildPlayer{
|
||||||
|
Player: player,
|
||||||
|
Queue: make([]TrackEntry, 0),
|
||||||
|
Volume: 100,
|
||||||
|
}
|
||||||
|
manager.players[guildID] = gp
|
||||||
|
return gp
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
|
||||||
|
if manager == nil {
|
||||||
|
return nil, false, fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join voice channel
|
||||||
|
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
if textChannelID != "" {
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.TextChannelID = textChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedTracks []lavalink.Track
|
||||||
|
|
||||||
|
manager.client.BestNode().LoadTracksHandler(
|
||||||
|
context.Background(),
|
||||||
|
query,
|
||||||
|
disgolink.NewResultHandler(
|
||||||
|
func(track lavalink.Track) {
|
||||||
|
loadedTracks = append(loadedTracks, track)
|
||||||
|
},
|
||||||
|
func(playlist lavalink.Playlist) {
|
||||||
|
if len(playlist.Tracks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
||||||
|
selected := playlist.Info.SelectedTrack
|
||||||
|
if selected >= 0 && selected < len(playlist.Tracks) {
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
||||||
|
},
|
||||||
|
func(tracks []lavalink.Track) {
|
||||||
|
if len(tracks) > 0 {
|
||||||
|
loadedTracks = append(loadedTracks, tracks[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func() {
|
||||||
|
},
|
||||||
|
func(err error) {
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(loadedTracks) == 0 {
|
||||||
|
return nil, false, fmt.Errorf("no tracks found for query")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some sources may return unplayable entries; pick the first with an encoded track.
|
||||||
|
firstPlayableIdx := 0
|
||||||
|
for idx := range loadedTracks {
|
||||||
|
if loadedTracks[idx].Encoded != "" {
|
||||||
|
firstPlayableIdx = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
||||||
|
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]TrackEntry, len(loadedTracks))
|
||||||
|
for idx, t := range loadedTracks {
|
||||||
|
entries[idx] = TrackEntry{
|
||||||
|
Track: t,
|
||||||
|
RequestedBy: requester,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
firstEntry := entries[0]
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
shouldStart := len(gp.Queue) == 0
|
||||||
|
gp.Queue = append(gp.Queue, entries...)
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if shouldStart {
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("start track: %w", err)
|
||||||
|
}
|
||||||
|
if gp.TextChannelID != "" {
|
||||||
|
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &firstEntry, shouldStart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !event.Reason.MayStartNext() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID := player.GuildID().String()
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp, ok := manager.players[guildID]
|
||||||
|
if !ok || len(gp.Queue) == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat current track
|
||||||
|
if gp.RepeatSong {
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop current and optionally cycle it to end
|
||||||
|
finished := gp.Queue[0]
|
||||||
|
gp.Queue = gp.Queue[1:]
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.Queue = append(gp.Queue, finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Pause(guildID string, pause bool) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.Paused = pause
|
||||||
|
if pause {
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Skip(guildID string) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// skip always advances regardless of repeat song
|
||||||
|
skipped := gp.Queue[0]
|
||||||
|
gp.Queue = gp.Queue[1:]
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.Queue = append(gp.Queue, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
}
|
||||||
|
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop(guildID string) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.Queue = nil
|
||||||
|
gp.Paused = false
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
||||||
|
if manager == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current := gp.Queue[0]
|
||||||
|
rest := make([]TrackEntry, len(gp.Queue)-1)
|
||||||
|
copy(rest, gp.Queue[1:])
|
||||||
|
return ¤t, rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleRepeatSong(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
gp.RepeatSong = !gp.RepeatSong
|
||||||
|
if gp.RepeatSong {
|
||||||
|
gp.RepeatQueue = false
|
||||||
|
}
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
gp.RepeatQueue = !gp.RepeatQueue
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.RepeatSong = false
|
||||||
|
}
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatFlags(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func pausedAndVolume(guildID string) (bool, int) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, 100
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
vol := gp.Volume
|
||||||
|
if vol <= 0 {
|
||||||
|
vol = 100
|
||||||
|
}
|
||||||
|
return gp.Paused, vol
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetVolume(guildID string, volume int) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
if volume < 0 {
|
||||||
|
volume = 0
|
||||||
|
}
|
||||||
|
if volume > 150 {
|
||||||
|
volume = 150
|
||||||
|
}
|
||||||
|
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.Volume = volume
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the embed so the displayed volume matches.
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
current, _ := GetQueue(guildID)
|
||||||
|
if current != nil {
|
||||||
|
postNowPlaying(guildID, textChannelID, *current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentNowPlayingMessageID(guildID string) string {
|
||||||
|
if manager == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
return gp.NowPlayingMsgID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent {
|
||||||
|
repeatSongStyle := discordgo.SecondaryButton
|
||||||
|
if repeatSong {
|
||||||
|
repeatSongStyle = discordgo.SuccessButton
|
||||||
|
}
|
||||||
|
repeatQueueStyle := discordgo.SecondaryButton
|
||||||
|
if repeatQueue {
|
||||||
|
repeatQueueStyle = discordgo.SuccessButton
|
||||||
|
}
|
||||||
|
|
||||||
|
return []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]},
|
||||||
|
CustomID: "music:toggle_pause",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "⏭️"},
|
||||||
|
CustomID: "music:skip",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: repeatSongStyle,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "🔂"},
|
||||||
|
CustomID: "music:repeat_song",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: repeatQueueStyle,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "🔁"},
|
||||||
|
CustomID: "music:repeat_queue",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.DangerButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "✖️"},
|
||||||
|
CustomID: "music:stop",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableNowPlaying(textChannelID, messageID string) {
|
||||||
|
if manager == nil || textChannelID == "" || messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disabled := nowPlayingComponents(true, false, false, false)
|
||||||
|
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: textChannelID,
|
||||||
|
ID: messageID,
|
||||||
|
Components: &disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postNowPlaying(guildID, textChannelID string, entry TrackEntry) {
|
||||||
|
if manager == nil || textChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp, ok := manager.players[guildID]
|
||||||
|
var oldMsgID string
|
||||||
|
if ok {
|
||||||
|
oldMsgID = gp.NowPlayingMsgID
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if oldMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, oldMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||||
|
paused, volume := pausedAndVolume(guildID)
|
||||||
|
|
||||||
|
info := entry.Track.Info
|
||||||
|
title := info.Title
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Author: &discordgo.MessageEmbedAuthor{
|
||||||
|
Name: info.Author,
|
||||||
|
IconURL: sourceIconURL(info.SourceName),
|
||||||
|
},
|
||||||
|
Title: title,
|
||||||
|
Color: 0x2B2D31, // matches Discord dark-ish embed accent
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true},
|
||||||
|
{Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true},
|
||||||
|
{Name: "Requested by", Value: entry.RequestedBy, Inline: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.URI != nil && *info.URI != "" {
|
||||||
|
embed.URL = *info.URI
|
||||||
|
}
|
||||||
|
|
||||||
|
if art := artworkURL(info); art != "" {
|
||||||
|
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
// Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks).
|
||||||
|
gp = manager.players[guildID]
|
||||||
|
if gp == nil {
|
||||||
|
gp = &guildPlayer{
|
||||||
|
Player: manager.client.Player(snowflake.MustParse(guildID)),
|
||||||
|
Queue: make([]TrackEntry, 0),
|
||||||
|
}
|
||||||
|
manager.players[guildID] = gp
|
||||||
|
}
|
||||||
|
gp.TextChannelID = textChannelID
|
||||||
|
gp.NowPlayingMsgID = msg.ID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func artworkURL(info lavalink.TrackInfo) string {
|
||||||
|
if info.ArtworkURL != nil && *info.ArtworkURL != "" {
|
||||||
|
return *info.ArtworkURL
|
||||||
|
}
|
||||||
|
// Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback.
|
||||||
|
if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" {
|
||||||
|
return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d lavalink.Duration, isStream bool) string {
|
||||||
|
if isStream {
|
||||||
|
return "Live"
|
||||||
|
}
|
||||||
|
secs := d.Seconds()
|
||||||
|
if secs < 0 {
|
||||||
|
secs = 0
|
||||||
|
}
|
||||||
|
h := secs / 3600
|
||||||
|
m := (secs % 3600) / 60
|
||||||
|
s := secs % 60
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceIconURL(source string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||||
|
case "youtube":
|
||||||
|
// Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds)
|
||||||
|
return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com"
|
||||||
|
case "soundcloud":
|
||||||
|
return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||||
|
if s == nil || guildID == "" || m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(m.Roles) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := s.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
djRoleID := ""
|
||||||
|
for _, r := range roles {
|
||||||
|
if r != nil && r.Name == "DJ" {
|
||||||
|
djRoleID = r.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if djRoleID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rid := range m.Roles {
|
||||||
|
if rid == djRoleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
func updateNowPlayingButtons(channelID, messageID string, guildID string) {
|
||||||
|
if manager == nil || channelID == "" || messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||||
|
paused, _ := pausedAndVolume(guildID)
|
||||||
|
comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused)
|
||||||
|
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: channelID,
|
||||||
|
ID: messageID,
|
||||||
|
Components: &comps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user