A lightweight Discord music bot written in go with minimal dependencies. Optimized for low CPU (not RAM) usage and responsive playback. Directly uses youtube-dl and ffmpeg for downloading media.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.

349 lines
7.7 KiB

package main
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"dcbot/dca0"
"dcbot/util"
"github.com/bwmarrin/discordgo"
)
////////////////////////////////
// Helper functions.
////////////////////////////////
// The first return value contains the voice channel ID, if it was found. If it
// was not found, it is set to "".
// The second return value indicates whether the voice channel was found.
func GetUserVoiceChannel(g *discordgo.Guild, userID string) (string, bool) {
for _, vs := range g.VoiceStates {
if vs.UserID == userID {
return vs.ChannelID, true
}
}
return "", false
}
////////////////////////////////
// Structs.
////////////////////////////////
type Playback struct {
Track
CmdCh chan dca0.Command
RespCh chan dca0.Response
Paused bool
Loop bool // Whether playback is looping right now.
}
type Track struct {
Title string // Title, if any.
Url string // Short URL, for example from YouTube.
MediaUrl string // Long URL of the associated media file.
}
// All methods of Client are thread safe, however manual locking is required
// when accessing any fields.
type Client struct {
sync.RWMutex
// The discordgo session.
s *discordgo.Session
// TextChannelID and VoiceChannelID indicate the current channels through
// which the bot should send text / audio. They may be set to "".
TextChannelID string
VoiceChannelID string
// Current audio playback.
Playback *Playback
// Queue.
Queue []*Track
}
func NewClient(s *discordgo.Session) *Client {
return &Client{
s: s,
}
}
func (c *Client) Messagef(format string, a ...interface{}) {
c.RLock()
if c.TextChannelID == "" {
fmt.Printf(format+"\n", a...)
} else {
c.s.ChannelMessageSend(c.TextChannelID, fmt.Sprintf(format, a...))
}
c.RUnlock()
}
// Updates the text channel and voice channel IDs. May set them to "" if there
// are none associated with the message.
func (c *Client) UpdateChannels(g *discordgo.Guild, m *discordgo.Message) {
c.Lock()
c.TextChannelID = m.ChannelID
vc, _ := GetUserVoiceChannel(g, m.Author.ID)
c.VoiceChannelID = vc
c.Unlock()
}
func (c *Client) GetTextChannelID() string {
c.RLock()
ret := c.TextChannelID
c.RUnlock()
return ret
}
func (c *Client) GetVoiceChannelID() string {
c.RLock()
ret := c.TextChannelID
c.RUnlock()
return ret
}
// Returns a COPY of c.Playback. Modifications to the returned Playback struct
// are NOT preserved, as it is a copy.
func (c *Client) GetPlaybackInfo() (p Playback, ok bool) {
c.RLock()
ret := c.Playback
c.RUnlock()
if ret == nil {
return Playback{}, false
} else {
return *ret, true
}
}
func (c *Client) QueueLen() int {
c.RLock()
l := len(c.Queue)
c.RUnlock()
return l
}
// Similarly to GetPlaybackInfo, this function returns a COPY. Any modifications
// are NOT preserved.
// ok field returns false if the index is out of bounds.
func (c *Client) QueueAt(i int) (t Track, ok bool) {
l := c.QueueLen()
if i >= l || i < 0 {
return Track{}, false
}
c.RLock()
ret := c.Queue[i]
c.RUnlock()
return *ret, true
}
func (c *Client) QueuePushBack(t *Track) {
c.Lock()
c.Queue = append(c.Queue, t)
c.Unlock()
}
func (c *Client) QueuePushFront(t *Track) {
c.Lock()
c.Queue = append([]*Track{t}, c.Queue...)
c.Unlock()
}
func (c *Client) QueuePopFront() (t Track, ok bool) {
t, ok = c.QueueAt(0)
if ok {
c.Lock()
c.Queue = c.Queue[1:]
c.Unlock()
}
return t, ok
}
// Deletes a single item at any position.
// Returns false if i was out of bounds.
func (c *Client) QueueDelete(i int) bool {
if i >= c.QueueLen() {
return false
}
c.Lock()
c.Queue = append(c.Queue[:i], c.Queue[i+1:]...)
c.Unlock()
return true
}
// Swaps two items in the queue.
// Returns false if a or b is out of bounds.
func (c *Client) QueueSwap(a, b int) bool {
if a == b {
return true
}
l := c.QueueLen()
if a >= l || b >= l || a < 0 || b < 0 {
return false
}
c.Lock()
c.Queue[a], c.Queue[b] = c.Queue[b], c.Queue[a]
c.Unlock()
return true
}
func (c *Client) QueueClear() {
c.Lock()
c.Queue = nil
c.Unlock()
}
////////////////////////////////
// Global variables.
////////////////////////////////
var clients map[string]*Client // Guild ID to client
var mClients sync.Mutex
var cfg Config
////////////////////////////////
// Main program.
////////////////////////////////
func main() {
if err := ReadConfig(&cfg); err != nil {
fmt.Println(err)
if err := WriteDefaultConfig(); err != nil {
fmt.Println("Failed to create the default configuration file:", err)
return
}
fmt.Println("Wrote the default configuration to " + configFile + ".")
fmt.Println("You will have to manually configure the token by editing " + configFile + ".")
return
}
if cfg.Token == tokenDefaultString {
fmt.Println("Please set your bot token in " + configFile + " first.")
return
}
// Check if all binary dependencies are installed correctly.
const notInstalledErrMsg = "Unable to find %s in the specified path '%s', please make sure it's installed correctly.\nYou can manually set its path by editing %s\n"
if !util.CheckInstalled(cfg.YtdlPath, "--version") {
fmt.Printf(notInstalledErrMsg, "youtube-dl", cfg.YtdlPath, configFile)
return
}
if !util.CheckInstalled(cfg.FfmpegPath, "-version") {
fmt.Printf(notInstalledErrMsg, "ffmpeg", cfg.FfmpegPath, configFile)
return
}
// Initialize client map.
clients = make(map[string]*Client)
// Initialize bot.
dg, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
fmt.Println("Error creating Discord session:", err)
return
}
dg.AddHandler(ready)
dg.AddHandler(banAdd)
dg.AddHandler(messageCreate)
// What information we need about guilds.
dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildBans
// Open the websocket and begin listening.
err = dg.Open()
if err != nil {
fmt.Println("Error opening Discord session:", err)
return
}
// Wait here until Ctrl+c or other term signal is received.
fmt.Println("Bot is now running. Press Ctrl+c to exit.")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc
fmt.Println("\nSignal received, closing Discord session.")
// Cleanly close down the Discord session.
dg.Close()
}
func ready(s *discordgo.Session, event *discordgo.Ready) {
u := s.State.User
fmt.Println("Logged in as", u.Username+"#"+u.Discriminator+".")
s.UpdateListeningStatus(cfg.Prefix + "help")
}
func banAdd(s *discordgo.Session, event *discordgo.GuildBanAdd) {
fmt.Println(event)
}
func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
// Ignore messages from the bot itself.
if m.Author.ID == s.State.User.ID {
return
}
var g *discordgo.Guild
g, err := s.State.Guild(m.GuildID)
if err != nil {
// Could not find guild.
s.ChannelMessageSend(m.ChannelID, "This bot only works in guilds (servers).")
return
}
var c *Client
mClients.Lock()
{
var ok bool
if c, ok = clients[m.GuildID]; !ok {
c = NewClient(s)
clients[m.GuildID] = c
}
}
mClients.Unlock()
// Update the text and voice channels associated with the client.
c.UpdateChannels(g, m.Message)
args, ok := CmdGetArgs(m.Content)
if !ok {
// Not a command.
return
}
if len(args) == 0 {
c.Messagef("No command specified. Type `%shelp` for help.", cfg.Prefix)
return
}
switch args[0] {
case "help":
commandHelp(c)
case "play":
commandPlay(s, g, c, args[1:])
case "seek":
commandSeek(c, args[1:])
case "pos":
commandPos(c)
case "loop":
commandLoop(c)
case "add":
commandAdd(c, args[1:], false)
case "queue":
commandQueue(c)
case "pause":
commandPause(c)
case "stop":
commandStop(c)
case "skip":
commandSkip(c)
case "delete":
commandDelete(c, args[1:])
case "swap":
commandSwap(c, args[1:])
case "shuffle":
commandShuffle(c)
}
}