Implement slash commands for playback control

This commit is contained in:
2026-09-21 18:48:17 +02:00
parent 347faf6b94
commit d05e721284
15 changed files with 809 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
package commands
import (
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgolink/v4/disgolink"
"discord-music-bot/internal/music"
)
type Dependencies struct {
Discord *bot.Client
Lavalink *disgolink.Client
Music *music.Manager
}
+109
View File
@@ -0,0 +1,109 @@
package commands
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/disgolink/v4/lavalink"
"github.com/disgoorg/snowflake/v2"
)
func guildID(event *events.ApplicationCommandInteractionCreate) (snowflake.ID, bool) {
id := event.GuildID()
if id == nil {
return 0, false
}
return *id, true
}
func memberVoiceChannel(discordClient *bot.Client, guild snowflake.ID, userID snowflake.ID) (snowflake.ID, bool) {
state, ok := discordClient.Caches.VoiceState(guild, userID)
if !ok || state.ChannelID == nil {
return 0, false
}
return *state.ChannelID, true
}
func ensureCanControlPlayback(discordClient *bot.Client, guild snowflake.ID, userID snowflake.ID) (bool, string) {
botChannelID, botConnected := memberVoiceChannel(discordClient, guild, discordClient.ApplicationID)
if !botConnected {
return true, ""
}
userChannelID, userConnected := memberVoiceChannel(discordClient, guild, userID)
if !userConnected || userChannelID != botChannelID {
return false, fmt.Sprintf("I'm currently active in <#%s>. Join that channel to control playback.", botChannelID)
}
return true, ""
}
func requirePlaybackAccess(discordClient *bot.Client, event *events.ApplicationCommandInteractionCreate) (snowflake.ID, error) {
gid, ok := guildID(event)
if !ok {
return 0, guildOnlyError(event)
}
if allowed, reason := ensureCanControlPlayback(discordClient, gid, event.User().ID); !allowed {
return 0, rejectEphemeral(event, reason)
}
return gid, nil
}
func reply(event *events.ApplicationCommandInteractionCreate, content string) error {
return event.CreateMessage(discord.NewMessageCreate().WithContent(content))
}
func replyEphemeral(event *events.ApplicationCommandInteractionCreate, content string) error {
return event.CreateMessage(discord.NewMessageCreate().WithContent(content).WithEphemeral(true))
}
func guildOnlyError(event *events.ApplicationCommandInteractionCreate) error {
return rejectEphemeral(event, "This command only works inside a server.")
}
func rejectEphemeral(event *events.ApplicationCommandInteractionCreate, content string) error {
if err := replyEphemeral(event, content); err != nil {
return err
}
return errors.New(content)
}
func replyWithError(event *events.ApplicationCommandInteractionCreate, userMessage string, cause error) error {
if err := replyEphemeral(event, userMessage); err != nil {
return err
}
return fmt.Errorf("%s: %w", userMessage, cause)
}
func intPtr(v int) *int {
return &v
}
func parsePosition(raw string) (lavalink.Duration, error) {
parts := strings.Split(raw, ":")
if len(parts) == 0 || len(parts) > 3 {
return 0, errors.New("expected seconds, mm:ss or hh:mm:ss")
}
segments := make([]int64, len(parts))
for i, part := range parts {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n < 0 {
return 0, errors.New("expected seconds, mm:ss or hh:mm:ss")
}
segments[i] = n
}
var totalSeconds int64
for _, segment := range segments {
totalSeconds = totalSeconds*60 + segment
}
return lavalink.Duration(totalSeconds) * lavalink.Second, nil
}
+62
View File
@@ -0,0 +1,62 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"discord-music-bot/internal/music"
)
type LoopCommand struct {
deps *Dependencies
}
func NewLoopCommand(deps *Dependencies) *LoopCommand {
return &LoopCommand{deps: deps}
}
func (c *LoopCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "loop",
Description: "Sets what happens when a track finishes",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "mode",
Description: "Loop mode",
Required: true,
Choices: []discord.ApplicationCommandOptionChoiceString{
{Name: "Off", Value: "off"},
{Name: "Track", Value: "track"},
{Name: "Queue", Value: "queue"},
},
},
},
}
}
func (c *LoopCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
value := event.SlashCommandInteractionData().String("mode")
var mode music.LoopMode
switch value {
case "off":
mode = music.LoopOff
case "track":
mode = music.LoopTrack
case "queue":
mode = music.LoopQueue
default:
return rejectEphemeral(event, fmt.Sprintf("Unknown loop mode %q.", value))
}
c.deps.Music.Guild(gid).SetLoopMode(mode)
return reply(event, fmt.Sprintf("Loop mode set to **%s**.", mode))
}
+37
View File
@@ -0,0 +1,37 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type NowPlayingCommand struct {
deps *Dependencies
}
func NewNowPlayingCommand(deps *Dependencies) *NowPlayingCommand {
return &NowPlayingCommand{deps: deps}
}
func (c *NowPlayingCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "nowplaying",
Description: "Shows the currently playing track",
}
}
func (c *NowPlayingCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, ok := guildID(event)
if !ok {
return guildOnlyError(event)
}
track, playing := c.deps.Music.Guild(gid).NowPlaying()
if !playing {
return reply(event, "Nothing is playing right now.")
}
return reply(event, fmt.Sprintf("Now playing: **%s** (%s)", track.Info.Title, track.Info.Length))
}
+35
View File
@@ -0,0 +1,35 @@
package commands
import (
"context"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type PauseCommand struct {
deps *Dependencies
}
func NewPauseCommand(deps *Dependencies) *PauseCommand {
return &PauseCommand{deps: deps}
}
func (c *PauseCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "pause",
Description: "Pauses the current track",
}
}
func (c *PauseCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
if err := c.deps.Music.Guild(gid).SetPaused(ctx, true); err != nil {
return replyWithError(event, "Could not pause playback.", err)
}
return reply(event, "Paused.")
}
+160
View File
@@ -0,0 +1,160 @@
package commands
import (
"context"
"fmt"
"strings"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/disgolink/v4/disgolink"
"github.com/disgoorg/disgolink/v4/lavalink"
"discord-music-bot/internal/music"
)
type PlayCommand struct {
deps *Dependencies
}
func NewPlayCommand(deps *Dependencies) *PlayCommand {
return &PlayCommand{deps: deps}
}
func (c *PlayCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "play",
Description: "Plays a song or playlist, or adds it to the queue",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "query",
Description: "Song name, song URL, or playlist URL",
Required: true,
},
},
}
}
func (c *PlayCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, ok := guildID(event)
if !ok {
return guildOnlyError(event)
}
channelID, ok := memberVoiceChannel(c.deps.Discord, gid, event.User().ID)
if !ok {
return rejectEphemeral(event, "Join a voice channel first.")
}
if allowed, reason := ensureCanControlPlayback(c.deps.Discord, gid, event.User().ID); !allowed {
return rejectEphemeral(event, reason)
}
query := event.SlashCommandInteractionData().String("query")
if err := event.DeferCreateMessage(false); err != nil {
return fmt.Errorf("deferring response: %w", err)
}
loaded, err := loadTracks(ctx, c.deps.Lavalink, query)
if err != nil {
return c.followUp(event, err.Error())
}
if err := c.deps.Discord.UpdateVoiceState(ctx, gid, &channelID, false, false); err != nil {
return c.followUp(event, fmt.Sprintf("Could not join your voice channel: %s", err))
}
result, err := c.deps.Music.Guild(gid).EnqueueMany(ctx, loaded.tracks)
if err != nil {
return c.followUp(event, fmt.Sprintf("Could not play that track: %s", err))
}
return c.followUp(event, playSummary(loaded, result))
}
func playSummary(loaded trackLoadResult, result music.EnqueueResult) string {
if len(loaded.tracks) <= 1 {
title := "that track"
if len(loaded.tracks) == 1 {
title = loaded.tracks[0].Info.Title
}
if result.PlayingNow {
return fmt.Sprintf("Now playing: **%s**", title)
}
return fmt.Sprintf("Added to queue: **%s**", title)
}
name := loaded.playlistName
if name == "" {
name = "the playlist"
}
if result.PlayingNow {
return fmt.Sprintf("Now playing **%s** and queued %d more track(s) from **%s**.",
result.Started.Info.Title, result.Queued, name)
}
return fmt.Sprintf("Added %d tracks from **%s** to the queue.", len(loaded.tracks), name)
}
func (c *PlayCommand) followUp(event *events.ApplicationCommandInteractionCreate, content string) error {
_, err := event.Client().Rest.UpdateInteractionResponse(event.ApplicationID(), event.Token(), discord.NewMessageUpdate().WithContent(content))
return err
}
func normalizeQuery(query string) string {
if strings.HasPrefix(query, "http://") || strings.HasPrefix(query, "https://") {
return query
}
return "ytsearch:" + query
}
type trackLoadResult struct {
tracks []lavalink.Track
playlistName string
}
func loadTracks(ctx context.Context, lavalinkClient *disgolink.Client, query string) (trackLoadResult, error) {
node := lavalinkClient.BestNode()
if node == nil {
return trackLoadResult{}, fmt.Errorf("no music node is available right now")
}
type outcome struct {
result trackLoadResult
err error
}
outcomeCh := make(chan outcome, 1)
node.Rest.LoadTracksHandler(ctx, normalizeQuery(query), disgolink.NewTrackLoadingResultHandler(
func(track lavalink.Track) {
outcomeCh <- outcome{result: trackLoadResult{tracks: []lavalink.Track{track}}}
},
func(playlist lavalink.Playlist) {
if len(playlist.Tracks) == 0 {
outcomeCh <- outcome{err: fmt.Errorf("that playlist is empty")}
return
}
outcomeCh <- outcome{result: trackLoadResult{
tracks: playlist.Tracks,
playlistName: playlist.Info.Name,
}}
},
func(tracks []lavalink.Track) {
if len(tracks) == 0 {
outcomeCh <- outcome{err: fmt.Errorf("no results found for %q", query)}
return
}
outcomeCh <- outcome{result: trackLoadResult{tracks: []lavalink.Track{tracks[0]}}}
},
func() {
outcomeCh <- outcome{err: fmt.Errorf("no results found for %q", query)}
},
func(err error) {
outcomeCh <- outcome{err: fmt.Errorf("failed to load track: %w", err)}
},
))
res := <-outcomeCh
return res.result, res.err
}
+44
View File
@@ -0,0 +1,44 @@
package commands
import (
"context"
"fmt"
"strings"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type QueueCommand struct {
deps *Dependencies
}
func NewQueueCommand(deps *Dependencies) *QueueCommand {
return &QueueCommand{deps: deps}
}
func (c *QueueCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "queue",
Description: "Shows the upcoming tracks",
}
}
func (c *QueueCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, ok := guildID(event)
if !ok {
return guildOnlyError(event)
}
tracks := c.deps.Music.Guild(gid).Queued()
if len(tracks) == 0 {
return reply(event, "The queue is empty.")
}
var builder strings.Builder
builder.WriteString("Up next:\n")
for i, track := range tracks {
fmt.Fprintf(&builder, "%d. **%s** (%s)\n", i+1, track.Info.Title, track.Info.Length)
}
return reply(event, builder.String())
}
+38
View File
@@ -0,0 +1,38 @@
package commands
import (
"context"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type Command interface {
Definition() discord.ApplicationCommandCreate
Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error
}
type Registry struct {
commands map[string]Command
}
func NewRegistry(cmds ...Command) *Registry {
registry := &Registry{commands: make(map[string]Command, len(cmds))}
for _, cmd := range cmds {
registry.commands[cmd.Definition().CommandName()] = cmd
}
return registry
}
func (r *Registry) Get(name string) (Command, bool) {
cmd, ok := r.commands[name]
return cmd, ok
}
func (r *Registry) Definitions() []discord.ApplicationCommandCreate {
definitions := make([]discord.ApplicationCommandCreate, 0, len(r.commands))
for _, cmd := range r.commands {
definitions = append(definitions, cmd.Definition())
}
return definitions
}
+47
View File
@@ -0,0 +1,47 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type RemoveCommand struct {
deps *Dependencies
}
func NewRemoveCommand(deps *Dependencies) *RemoveCommand {
return &RemoveCommand{deps: deps}
}
func (c *RemoveCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "remove",
Description: "Removes a track from the queue",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionInt{
Name: "position",
Description: "Position in the queue, as shown by /queue (1 is next)",
Required: true,
MinValue: intPtr(1),
},
},
}
}
func (c *RemoveCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
position := event.SlashCommandInteractionData().Int("position")
track, ok := c.deps.Music.Guild(gid).RemoveQueued(position - 1)
if !ok {
return rejectEphemeral(event, fmt.Sprintf("There's no track at position %d in the queue.", position))
}
return reply(event, fmt.Sprintf("Removed **%s** from the queue.", track.Info.Title))
}
+35
View File
@@ -0,0 +1,35 @@
package commands
import (
"context"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type ResumeCommand struct {
deps *Dependencies
}
func NewResumeCommand(deps *Dependencies) *ResumeCommand {
return &ResumeCommand{deps: deps}
}
func (c *ResumeCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "resume",
Description: "Resumes the current track",
}
}
func (c *ResumeCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
if err := c.deps.Music.Guild(gid).SetPaused(ctx, false); err != nil {
return replyWithError(event, "Could not resume playback.", err)
}
return reply(event, "Resumed.")
}
+63
View File
@@ -0,0 +1,63 @@
package commands
import (
"context"
"errors"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"discord-music-bot/internal/music"
)
type SeekCommand struct {
deps *Dependencies
}
func NewSeekCommand(deps *Dependencies) *SeekCommand {
return &SeekCommand{deps: deps}
}
func (c *SeekCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "seek",
Description: "Jumps to a position in the current track",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "position",
Description: "Position to seek to: seconds, mm:ss or hh:mm:ss (e.g. 90 or 1:30)",
Required: true,
},
},
}
}
func (c *SeekCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
raw := event.SlashCommandInteractionData().String("position")
position, err := parsePosition(raw)
if err != nil {
return rejectEphemeral(event, fmt.Sprintf("Invalid position %q: %s", raw, err))
}
if err := c.deps.Music.Guild(gid).Seek(ctx, position); err != nil {
return replyWithError(event, seekErrorMessage(err), err)
}
return reply(event, fmt.Sprintf("Seeked to **%s**.", position))
}
func seekErrorMessage(err error) string {
switch {
case errors.Is(err, music.ErrNoTrackPlaying):
return "Nothing is playing right now."
case errors.Is(err, music.ErrStreamNotSeekable):
return "Can't seek within a live stream."
default:
return "Could not seek."
}
}
+37
View File
@@ -0,0 +1,37 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type ShuffleCommand struct {
deps *Dependencies
}
func NewShuffleCommand(deps *Dependencies) *ShuffleCommand {
return &ShuffleCommand{deps: deps}
}
func (c *ShuffleCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "shuffle",
Description: "Shuffles the upcoming tracks in the queue",
}
}
func (c *ShuffleCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
count := c.deps.Music.Guild(gid).Shuffle()
if count == 0 {
return reply(event, "The queue is empty, nothing to shuffle.")
}
return reply(event, fmt.Sprintf("Shuffled **%d** queued track(s).", count))
}
+41
View File
@@ -0,0 +1,41 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type SkipCommand struct {
deps *Dependencies
}
func NewSkipCommand(deps *Dependencies) *SkipCommand {
return &SkipCommand{deps: deps}
}
func (c *SkipCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "skip",
Description: "Skips the current track",
}
}
func (c *SkipCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
track, hasNext, err := c.deps.Music.Guild(gid).Skip(ctx)
if err != nil {
return replyWithError(event, "Could not skip the current track.", err)
}
if !hasNext {
return reply(event, "Skipped. Queue is empty.")
}
return reply(event, fmt.Sprintf("Skipped. Now playing: **%s**", track.Info.Title))
}
+40
View File
@@ -0,0 +1,40 @@
package commands
import (
"context"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type StopCommand struct {
deps *Dependencies
}
func NewStopCommand(deps *Dependencies) *StopCommand {
return &StopCommand{deps: deps}
}
func (c *StopCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "stop",
Description: "Stops playback, clears the queue and leaves the voice channel",
}
}
func (c *StopCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
if err := c.deps.Music.Guild(gid).Stop(ctx); err != nil {
return replyWithError(event, "Could not stop playback.", err)
}
if err := c.deps.Discord.UpdateVoiceState(ctx, gid, nil, false, false); err != nil {
return replyWithError(event, "Stopped playback, but could not leave the voice channel.", err)
}
return reply(event, "Stopped playback and left the voice channel.")
}
+47
View File
@@ -0,0 +1,47 @@
package commands
import (
"context"
"fmt"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)
type VolumeCommand struct {
deps *Dependencies
}
func NewVolumeCommand(deps *Dependencies) *VolumeCommand {
return &VolumeCommand{deps: deps}
}
func (c *VolumeCommand) Definition() discord.ApplicationCommandCreate {
return discord.SlashCommandCreate{
Name: "volume",
Description: "Sets the playback volume",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionInt{
Name: "level",
Description: "Volume percentage, 0-200 (default 100)",
Required: true,
MinValue: intPtr(0),
MaxValue: intPtr(200),
},
},
}
}
func (c *VolumeCommand) Execute(ctx context.Context, event *events.ApplicationCommandInteractionCreate) error {
gid, err := requirePlaybackAccess(c.deps.Discord, event)
if err != nil {
return err
}
level := event.SlashCommandInteractionData().Int("level")
if err := c.deps.Music.Guild(gid).SetVolume(ctx, level); err != nil {
return replyWithError(event, "Could not change the volume.", err)
}
return reply(event, fmt.Sprintf("Volume set to **%d%%**.", level))
}