1
0
mirror of https://github.com/mgerb/go-discord-bot synced 2026-01-09 08:32:48 +00:00
Files
go-discord-bot/server/bothandlers/sounds.go
2022-04-24 20:25:50 -05:00

489 lines
12 KiB
Go

package bothandlers
import (
"io/ioutil"
"math/rand"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/mgerb/go-discord-bot/server/db"
"github.com/mgerb/go-discord-bot/server/webserver/model"
"github.com/bwmarrin/discordgo"
"github.com/mgerb/go-discord-bot/server/config"
"github.com/mgerb/go-discord-bot/server/util"
log "github.com/sirupsen/logrus"
)
const (
channels int = 2 // 1 for mono, 2 for stereo
sampleRate int = 48000 // audio sampling rate - apparently a standard for opus
opusFrameSize int = 960 // at 48kHz the permitted values are 120, 240, 480, or 960
maxSoundQueue int = 10 // max amount of sounds that can be queued at one time
voiceClipQueuePacketSize int = 2000 // this packet size equates to roughly 40 seconds of audio
)
// ActiveConnections - current active bot connections
// store our connection objects in a map tied to a guild id
var ActiveConnections = make(map[string]*AudioConnection)
// AudioConnection -
type AudioConnection struct {
Guild *discordgo.Guild `json:"guild"`
Session *discordgo.Session `json:"-"`
CurrentChannel *discordgo.Channel `json:"current_channel"`
Sounds map[string]*AudioClip `json:"-"`
SoundQueue chan string `json:"-"`
VoiceClipQueue chan *discordgo.Packet `json:"-"`
SoundPlayingLock bool `json:"-"`
AudioListenerLock bool `json:"-"`
Mutex *sync.Mutex `json:"-"` // mutex for single audio connection
}
// AudioClip -
type AudioClip struct {
Name string
Extension string
Content [][]byte
}
// VoiceStateHandler - when users enter voice channels
func VoiceStateHandler(s *discordgo.Session, v *discordgo.VoiceStateUpdate) {
if conn := ActiveConnections[v.GuildID]; conn != nil {
voiceConnection := conn.getVoiceConnection()
if voiceConnection != nil && voiceConnection.Ready && voiceConnection.ChannelID == v.VoiceState.ChannelID &&
(v.BeforeUpdate == nil || v.BeforeUpdate.ChannelID != voiceConnection.ChannelID) {
user, err := model.UserGet(db.GetConn(), v.VoiceState.UserID)
if err != nil {
log.Error(err)
return
}
if user.VoiceJoinSound != nil {
time.Sleep(time.Second)
conn.PlayAudio(*user.VoiceJoinSound, nil, nil)
}
}
}
}
// SoundsHandler - play sounds
func SoundsHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
// get channel state to get guild id
c, err := s.State.Channel(m.ChannelID)
if err != nil {
// Could not find channel.
log.Error("Unable to find channel.")
return
}
// check to see if active connection object exists
if _, ok := ActiveConnections[c.GuildID]; !ok {
// Find the guild for that channel.
newGuild, err := s.State.Guild(c.GuildID)
if err != nil {
log.Error(err)
return
}
// create new connection instance
ActiveConnections[c.GuildID] = &AudioConnection{
Guild: newGuild,
Session: s,
Sounds: make(map[string]*AudioClip, 0),
SoundQueue: make(chan string, maxSoundQueue),
Mutex: &sync.Mutex{},
AudioListenerLock: false,
}
}
ActiveConnections[c.GuildID].handleMessage(m)
}
func (conn *AudioConnection) handleMessage(m *discordgo.MessageCreate) {
// check if valid command
if strings.HasPrefix(m.Content, config.Config.BotPrefix) {
command := strings.TrimPrefix(m.Content, config.Config.BotPrefix)
switch command {
case "summon":
conn.summon(m)
case "dismiss":
conn.dismiss()
case "clip":
conn.clipAudio(m)
case "random":
conn.PlayRandomAudio(m, nil)
// restart command handled in bot file - needed here to break out
case "restart":
break
default:
conn.PlayAudio(command, m, nil)
}
}
}
// dismiss bot from current channel if it's in one
func (conn *AudioConnection) dismiss() {
voiceConnection := conn.getVoiceConnection()
if voiceConnection != nil && !conn.SoundPlayingLock && len(conn.SoundQueue) == 0 {
err := voiceConnection.Disconnect()
if err != nil {
log.Error(err)
}
}
}
// summon bot to channel that user is currently in
func (conn *AudioConnection) summon(m *discordgo.MessageCreate) {
voiceConnection := conn.getVoiceConnection()
// Join the channel the user issued the command from if not in it
if voiceConnection == nil || voiceConnection.ChannelID != m.ChannelID {
var err error
// Find the channel that the message came from.
c, err := conn.Session.State.Channel(m.ChannelID)
if err != nil {
// Could not find channel.
log.Error("User channel not found.")
return
}
// Find the guild for that channel.
g, err := conn.Session.State.Guild(c.GuildID)
if err != nil {
log.Error(err)
return
}
// Look for the message sender in that guilds current voice states
for _, vs := range g.VoiceStates {
if vs.UserID == m.Author.ID {
_, err = conn.Session.ChannelVoiceJoin(g.ID, vs.ChannelID, false, false)
if err != nil {
log.Error(err)
return
}
// set the current channel
conn.CurrentChannel = c
go conn.startAudioListener()
}
}
}
}
func (conn *AudioConnection) getVoiceConnection() *discordgo.VoiceConnection {
return conn.Session.VoiceConnections[conn.Guild.ID]
}
// PlayRandomAudio - play a random sound clip
func (conn *AudioConnection) PlayRandomAudio(m *discordgo.MessageCreate, userID *string) {
files, _ := ioutil.ReadDir(config.Config.SoundsPath)
if len(files) > 0 {
randomIndex := rand.Intn(len(files))
arr := strings.Split(files[randomIndex].Name(), ".")
if len(arr) > 0 && arr[0] != "" {
conn.PlayAudio(arr[0], m, userID)
}
}
}
// PlayAudio - play audio in channel that user is in
// if MessageCreate is null play in current channel
func (conn *AudioConnection) PlayAudio(soundName string, m *discordgo.MessageCreate, userID *string) {
voiceConnection := conn.getVoiceConnection()
// summon bot to channel if new message passed in
if m != nil {
conn.summon(m)
} else if voiceConnection == nil || !voiceConnection.Ready {
log.Error("[PlayAudio] Voice connection is not ready")
return
}
// check if sound exists in memory
if _, ok := conn.Sounds[soundName]; !ok {
// try to load the sound if not found in memory
err := conn.loadFile(soundName)
if err != nil {
log.Error(err)
return
}
}
// add sound to queue if queue isn't full
select {
case conn.SoundQueue <- soundName:
var newUserID *string
fromWebUI := false
// from discord
if m != nil && m.Author != nil {
newUserID = &m.Author.ID
} else {
fromWebUI = true
newUserID = userID
}
// newUserID will be null if bot plays voice join sound
if newUserID != nil {
// log event when user plays sound clip
err := model.LogSoundPlayedEvent(db.GetConn(), *newUserID, soundName, fromWebUI)
if err != nil {
log.Error(err)
}
}
default:
break
}
// start playing sounds in queue if not already playing
if !conn.SoundPlayingLock {
conn.playSoundsInQueue()
}
}
// playSoundsInQueue - play sounds until audio queue is empty
func (conn *AudioConnection) playSoundsInQueue() {
conn.toggleSoundPlayingLock(true)
voiceConnection := conn.getVoiceConnection()
if voiceConnection == nil {
return
}
// Start speaking.
err := voiceConnection.Speaking(true)
if err != nil {
log.Error(err)
}
for {
select {
case newSoundName := <-conn.SoundQueue:
if !voiceConnection.Ready {
return
}
// Send the buffer data.
for _, buff := range conn.Sounds[newSoundName].Content {
voiceConnection.OpusSend <- buff
}
// Sleep for a specificed amount of time before ending.
time.Sleep(100 * time.Millisecond)
default:
// Stop speaking
err := voiceConnection.Speaking(false)
if err != nil {
log.Error(err)
}
conn.toggleSoundPlayingLock(false)
return
}
}
}
func (conn *AudioConnection) toggleSoundPlayingLock(playing bool) {
conn.Mutex.Lock()
conn.SoundPlayingLock = playing
conn.Mutex.Unlock()
}
// load audio file into memory
func (conn *AudioConnection) loadFile(fileName string) error {
extension, err := util.GetFileExtension(config.Config.SoundsPath, fileName)
if err != nil {
return err
}
opusData, err := util.GetFileOpusData(path.Join(config.Config.SoundsPath, fileName+extension), channels, opusFrameSize, sampleRate)
if err != nil {
return err
}
conn.Sounds[fileName] = &AudioClip{
Content: opusData,
Name: fileName,
Extension: extension,
}
return nil
}
func (conn *AudioConnection) clipAudio(m *discordgo.MessageCreate) {
if len(conn.VoiceClipQueue) < 10 {
_, err := conn.Session.ChannelMessageSend(m.ChannelID, "Clip failed.")
if err != nil {
log.Error(err)
}
} else {
writePacketsToFile(m.Author.Username, conn.VoiceClipQueue)
_, err := conn.Session.ChannelMessageSend(m.ChannelID, "Sound clipped!")
if err != nil {
log.Error(err)
}
}
}
func writePacketsToFile(username string, packets chan *discordgo.Packet) {
// create clips folder if it does not exist
if _, err := os.Stat(config.Config.ClipsPath); os.IsNotExist(err) {
err := os.Mkdir(config.Config.ClipsPath, os.ModePerm)
if err != nil {
log.Error(err)
}
}
opusData := map[uint32][][]byte{}
loop:
for {
select {
case p := <-packets:
// separate opus data for each channel
opusData[p.SSRC] = append(opusData[p.SSRC], p.Opus)
default:
break loop
}
}
for key, opus := range opusData {
pcmData, err := util.OpusToPCM(opus, sampleRate, channels)
if err != nil {
log.Error(err)
continue
}
// construct filename
timestamp := time.Now().UTC().Format("2006-01-02") + "-" + strconv.Itoa(int(time.Now().Unix()))
filename := config.Config.ClipsPath + "/" + timestamp + "-" + strconv.Itoa(int(key)) + "-" + username + ".wav"
err = util.SavePCMToWavFile(pcmData, filename, sampleRate, channels)
if err != nil {
log.Error(err)
}
}
}
// start listening to the voice channel
// endless loop - look into closing if ever set up for multiple servers to prevent memory leak
// should be fine to keep this open for now
func (conn *AudioConnection) startAudioListener() {
if conn.AudioListenerLock {
return
}
log.Info("[startAudioListener] Voice connection listener started")
conn.AudioListenerLock = true
if conn.VoiceClipQueue == nil {
conn.VoiceClipQueue = make(chan *discordgo.Packet, voiceClipQueuePacketSize)
}
localSleep := func() {
time.Sleep(time.Second / 10)
}
// Concurrently check and see if voice connection is not in ready state
// because we need to exit the sound handler.
vcExitChan := make(chan bool, 1)
go func() {
for {
voiceConnection := conn.getVoiceConnection()
if voiceConnection != nil {
voiceConnection.RLock()
ready := voiceConnection.Ready
voiceConnection.RUnlock()
if !ready {
vcExitChan <- true
}
}
localSleep()
}
}()
for {
voiceConnection := conn.getVoiceConnection()
if voiceConnection == nil {
localSleep()
continue
}
voiceConnection.RLock()
ready := voiceConnection.Ready
voiceConnection.RUnlock()
// if connection lost wait for ready
if !ready {
localSleep()
continue
}
select {
// grab incoming audio
case opusChannel, ok := <-voiceConnection.OpusRecv:
if !ok {
continue
}
// if channel is full trim off from beginning
if len(conn.VoiceClipQueue) == cap(conn.VoiceClipQueue) {
<-conn.VoiceClipQueue
}
// add current packet to channel queue
conn.VoiceClipQueue <- opusChannel
break
// if voice is interrupted continue loop (e.g. disconnects)
case <-vcExitChan:
log.Info("[startAudioListener] exitChan is exiting")
localSleep()
}
}
}