diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b645d11 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git/ +bk/ +scripts/ +vendor/ + +.DS_Store +config.json +speakerbot +README.md +.travis.yml + +test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492443c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bk/ +vendor/ + +.DS_Store +config.json +speakerbot + +test.go diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a21136c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: go +go: + - 1.6 +install: make +script: ./scripts/test.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3544efd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM alpine:3.3 +MAINTAINER Dustin Blackman + +ENV GOROOT /usr/lib/go +ENV GOPATH /gopath +ENV GOBIN /gopath/bin +ENV PATH $PATH:$GOROOT/bin:$GOPATH/bin + +COPY . /gopath/src/github.com/dustinblackman/speakerbot + +RUN apk add --update ffmpeg opus opus-dev bash git make pkgconfig build-base && \ + apk add go --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community/ --allow-untrusted && \ + cd /gopath/src/github.com/dustinblackman/speakerbot && \ + make && \ + make build && \ + mkdir /app && \ + mv ./speakerbot /app/ && \ + apk del go git make pkgconfig opus-dev build-base && \ + rm -rf /usr/share/man /tmp/* /var/tmp/* /var/cache/apk/* /gopath + +WORKDIR /app +CMD ["./speakerbot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3699b8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Dustin Blackman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..95416b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +all: + which glide || go get github.com/Masterminds/glide && glide install + +build: + go build -o speakerbot *.go + +dev: + which CompileDaemon || go get github.com/githubnemo/CompileDaemon && CompileDaemon -directory=. -exclude-dir=.git -exclude-dir=vendor -exclude=speakerbot -command=./speakerbot + +docker-build: + docker build -t dustinblackman/speakerbot . + +test: + ./scripts/test.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd12cce --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +![Speakerbot](assets/banner.jpg) + +Speakerbot is a multiserver music bot for Discord written in Go. Supports Youtube links and querying Youtube, as well as on the fly converting resulting in instant playback (no wait times between songs!). + +## Commands + +- `!play` - Queues/Plays Youtube link, or searches Youtube and picks the first result +- `!skip` - Skips current track +- `!stops` - Stops playing and clears queue + +## Installation + +Speakerbot requires both [`ffmpeg`](https://ffmpeg.org/download.html) and [`opus-tools`](https://www.opus-codec.org/downloads/) to be installed locally and available in PATH. Currently tested with Go 1.6 on OSX. + +```bash +go get github.com/dustinblackman/speakerbot +make +make build +``` + +A configuration file is available to plugin your Discord email and password, as well as a Google API key that can search Youtube. You can either rename the `config.example.json` to `config.json`, or copy/paste the following. + +```json +{ + "email": "", + "password": "", + "googleKey": "" +} +``` + +Lastly, start Speakerbot + +```bash +./speakerbot +``` + +## Docker + +An automated docker build is available here. You can boot it up with `docker-compose` like so as an example. + +```yaml +speakerbot: + image: dustinblackman/speakerbot + environment: + EMAIL: + PASSWORD: + GOOGLEKEY: +``` + +## Contribute/Development + +If you wish to contribute and add features to Speakerbot, feel free! This app was just a practice app to begin learning Go, so there won't be very much (or any at all) future development from myself. + +Make sure to run `make test` before submitting a PR. + +## [License](LICENSE) + +MIT diff --git a/assets/banner.jpg b/assets/banner.jpg new file mode 100644 index 0000000..8964046 Binary files /dev/null and b/assets/banner.jpg differ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..4421eec --- /dev/null +++ b/config.example.json @@ -0,0 +1,5 @@ +{ + "email": "", + "password": "", + "googleKey": "" +} diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..d7f980b --- /dev/null +++ b/glide.lock @@ -0,0 +1,45 @@ +hash: 167e1a461095a784a84c473dfe957278e3b1683cb883e51ac6267498e4822b22 +updated: 2016-03-04T09:51:48.748721295-05:00 +imports: +- name: github.com/bwmarrin/discordgo + version: d0c30f0f14cf4bbc89124ea6fd2c3d7946d4de24 +- name: github.com/davecgh/go-spew + version: 5215b55f46b2b919f50a1df0eaa5886afe4e3b3d + subpackages: + - spew +- name: github.com/gorilla/websocket + version: c45a635370221f34fea2d5163fd156fcb4e38e8a +- name: github.com/hashicorp/hcl + version: 71c7409f1abba841e528a80556ed2c67671744c3 + subpackages: + - hcl/ast + - hcl/parser + - hcl/token + - json/parser + - hcl/scanner + - hcl/strconv + - json/scanner + - json/token +- name: github.com/Jeffail/gabs + version: 1ad8a462121027c511ab337b98028029352ca356 +- name: github.com/layeh/gopus + version: f312e86d7c7afea779f2503e3e26c22ee916c992 +- name: github.com/moul/http2curl + version: 1812aee76a1ce98d604a44200c6a23c689b17a89 +- name: github.com/oleiade/lane + version: e2df44de3dbdd0074d315d1fa74f15a090802b45 +- name: github.com/paked/configure + version: edda2643bab9cdb01079ebfcf78fc2037fa954f8 +- name: github.com/parnurzeal/gorequest + version: c73179dd31355d86bd7692de9b47623d6d0fa696 +- name: golang.org/x/crypto + version: 5dc8cb4b8a8eb076cbb5a06bc3b8682c15bdbbd3 + subpackages: + - nacl/secretbox + - poly1305 + - salsa20/salsa +- name: golang.org/x/net + version: 6acef71eb69611914f7a30939ea9f6e194c78172 + subpackages: + - publicsuffix +devImports: [] diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..56c3521 --- /dev/null +++ b/glide.yaml @@ -0,0 +1,12 @@ +package: github.com/dustinblackman/speakerbot +import: +- package: github.com/bwmarrin/discordgo + version: develop +- package: github.com/davecgh/go-spew + subpackages: + - spew +- package: github.com/layeh/gopus +- package: github.com/oleiade/lane +- package: github.com/Jeffail/gabs +- package: github.com/paked/configure +- package: github.com/parnurzeal/gorequest diff --git a/main.go b/main.go new file mode 100644 index 0000000..05342ba --- /dev/null +++ b/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/paked/configure" +) + +const ( + // VERSION of Speakerbot + VERSION = "1.0.0" +) + +var ( + conf = configure.New() + email = conf.String("email", "", "Discord email address") + password = conf.String("password", "", "Discord password") + googleKey = conf.String("googleKey", "", "Google API key for Youtube API") +) + +func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + if s.State.Ready.User.Username == m.Author.Username { + return + } + + fmt.Printf("%20s %20s %20s > %s\n", m.ChannelID, time.Now().Format(time.Stamp), m.Author.Username, m.Content) + + if m.Content[:1] == "!" { + channel, _ := s.Channel(m.ChannelID) + serverID := channel.GuildID + method := strings.Split(m.Content, " ")[0][1:] + + if method == "play" { + youtubeLink, youtubeTitle, err := GetYoutubeURL(strings.Split(m.Content, " ")[1]) + if err != nil { + fmt.Println(err) + s.ChannelMessageSend(m.ChannelID, "Error: No video found") + return + } + + if voiceInstances[serverID] != nil { + voiceInstances[serverID].QueueVideo(youtubeLink) + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Queued: %s", youtubeTitle)) + } else { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Playing: %s", youtubeTitle)) + go CreateVoiceInstance(youtubeLink, serverID) + } + } else if method == "stop" && voiceInstances[serverID] != nil { + voiceInstances[serverID].StopVideo() + } else if method == "skip" && voiceInstances[serverID] != nil { + voiceInstances[serverID].SkipVideo() + } else if method == "help" { + s.ChannelMessageSend(m.ChannelID, `**!play** - Search/Play Youtube link, queues up if another track is playing +**!skip** - Skip current playing track +**!stop** - Stops tracks and clears queue`) + } + } +} + +func main() { + // Pull in configuration + conf.Use(configure.NewFlag()) + conf.Use(configure.NewEnvironment()) + if _, err := os.Stat("config.json"); err == nil { + conf.Use(configure.NewJSONFromFile("config.json")) + } + conf.Parse() + + discord, err := discordgo.New(*email, *password) + if err != nil { + fmt.Println("Error logging in") + fmt.Println(err) + } + + discord.AddHandler(messageCreate) + + // Open the websocket and begin listening. + err = discord.Open() + if err != nil { + fmt.Println(err) + } + + fmt.Println("Listening...") + lock := make(chan int) + <-lock +} diff --git a/pcm.go b/pcm.go new file mode 100644 index 0000000..76f66a7 --- /dev/null +++ b/pcm.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "sync" + + "github.com/bwmarrin/discordgo" + "github.com/layeh/gopus" +) + +var ( + sendpcm bool + recv chan *discordgo.Packet + mu sync.Mutex +) + +const ( + maxBytes int = (frameSize * 2) * 2 // max size of opus data +) + +// SendPCM will receive on the provied channel encode +// received PCM data into Opus then send that to Discordgo +func SendPCM(v *discordgo.Voice, pcm <-chan []int16) { + mu.Lock() + if sendpcm || pcm == nil { + mu.Unlock() + return + } + sendpcm = true + mu.Unlock() + defer func() { sendpcm = false }() + + opusEncoder, err := gopus.NewEncoder(frameRate, channels, gopus.Audio) + if err != nil { + fmt.Println("NewEncoder Error:", err) + return + } + + for { + // read pcm from chan, exit if channel is closed. + recv, ok := <-pcm + if !ok { + fmt.Println("PCM Channel closed.") + return + } + + // try encoding pcm frame with Opus + opus, err := opusEncoder.Encode(recv, frameSize, maxBytes) + if err != nil { + fmt.Println("Encoding Error:", err) + return + } + + if v.Ready == false || v.OpusSend == nil { + // fmt.Printf("Discordgo not ready for opus packets. %+v : %+v", v.Ready, v.OpusSend) + return + } + // send encoded opus data to the sendOpus channel + v.OpusSend <- opus + } +} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..e33da1f --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,18 @@ +set -e +cd $PWD + +go vet + +if [[ $(golint *.go) ]]; then + golint *.go + echo "golint failed" + exit 1 +fi + +if [[ $(gofmt -d ./*.go) ]]; then + gofmt -d ./*.go + echo "gofmt returned suggested changes, please run gofmt first. Exiting..." + exit 1 +fi + +echo "Hooray! Tests passed." diff --git a/voice.go b/voice.go new file mode 100644 index 0000000..c7d5b9b --- /dev/null +++ b/voice.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/binary" + "fmt" + "io" + "log" + "net/http" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/oleiade/lane" +) + +var ( + run *exec.Cmd + voiceInstances = map[string]*VoiceInstance{} +) + +const ( + channels int = 2 // 1 for mono, 2 for stereo + frameRate int = 48000 // audio sampling rate + frameSize int = 960 // uint16 size of each audio frame +) + +// VoiceInstance is created for each +type VoiceInstance struct { + discord *discordgo.Session + queue *lane.Queue + pcmChannel chan []int16 + serverID string + skip bool + stop bool + trackPlaying bool +} + +func (vi *VoiceInstance) playVideo(url string) { + vi.trackPlaying = true + + resp, err := http.Get(url) + if err != nil { + log.Printf("Http.Get\nerror: %s\ntarget: %s\n", err, url) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + log.Printf("reading answer: non 200 status code received: '%s'", err) + } + + // Create a shell command "object" to run. + run = exec.Command("ffmpeg", "-i", "-", "-f", "s16le", "-ar", strconv.Itoa(frameRate), "-ac", strconv.Itoa(channels), "pipe:1") + run.Stdin = resp.Body + stdout, err := run.StdoutPipe() + if err != nil { + fmt.Println("StdoutPipe Error:", err) + return + } + + // Starts ffmpeg + err = run.Start() + if err != nil { + fmt.Println("RunStart Error:", err) + return + } + + // buffer used during loop below + audiobuf := make([]int16, frameSize*channels) + + vi.discord.Voice.Speaking(true) + defer vi.discord.Voice.Speaking(false) + + for { + // read data from ffmpeg stdout + err = binary.Read(stdout, binary.LittleEndian, &audiobuf) + if err == io.EOF || err == io.ErrUnexpectedEOF { + break + } + if err != nil { + fmt.Println("error reading from ffmpeg stdout :", err) + break + } + if vi.stop == true || vi.skip == true { + run.Process.Kill() + break + } + vi.pcmChannel <- audiobuf + } + + vi.trackPlaying = false +} + +// StopVideo marks to stop all tracks and clears queue on the next binary read. +func (vi *VoiceInstance) StopVideo() { + vi.stop = true +} + +// SkipVideo skips the current playing track +func (vi *VoiceInstance) SkipVideo() { + vi.skip = true +} + +func (vi *VoiceInstance) connectVoice() { + vi.discord, _ = discordgo.New(*email, *password) + + // Open the websocket and begin listening. + err := vi.discord.Open() + if err != nil { + fmt.Println(err) + } + + channels, err := vi.discord.GuildChannels(vi.serverID) + + var voiceChannel string + voiceChannels := []string{} + for _, channel := range channels { + if channel.Type == "voice" { + voiceChannels = append(voiceChannels, channel.ID) + if strings.Contains(strings.ToLower(channel.Name), "music") && voiceChannel == "" { + voiceChannel = channel.ID + } + } + } + + if voiceChannel == "" { + fmt.Println("Selecting first channel") + voiceChannel = voiceChannels[0] + } + + err = vi.discord.ChannelVoiceJoin(vi.serverID, voiceChannel, false, true) + if err != nil { + fmt.Println(err) + return + } + + // Hacky loop to prevent sending on a nil channel. + // TODO: Find a better way. + for vi.discord.Voice.Ready == false { + runtime.Gosched() + } +} + +// QueueVideo places a Youtube link in a queue +func (vi *VoiceInstance) QueueVideo(youtubeLink string) { + fmt.Println("Queuing video") + vi.queue.Enqueue(youtubeLink) +} + +func (vi *VoiceInstance) processQueue() { + if vi.trackPlaying == false { + for { + vi.skip = false + link := vi.queue.Dequeue() + if link == nil || vi.stop == true { + break + } + vi.playVideo(link.(string)) + } + + // No more tracks in queue? Cleanup. + fmt.Println("Closing connections") + close(vi.pcmChannel) + vi.discord.Voice.Close() + vi.discord.Close() + delete(voiceInstances, vi.serverID) + fmt.Println("Done") + } +} + +// CreateVoiceInstance accepts both a youtube query and a server id, boots up the voice connection, and plays the track. +func CreateVoiceInstance(youtubeLink string, serverID string) { + vi := new(VoiceInstance) + voiceInstances[serverID] = vi + + fmt.Println("Connecting Voice...") + vi.serverID = serverID + vi.queue = lane.NewQueue() + vi.connectVoice() + + vi.pcmChannel = make(chan []int16, 2) + go SendPCM(vi.discord.Voice, vi.pcmChannel) + + vi.QueueVideo(youtubeLink) + vi.processQueue() +} diff --git a/youtube.go b/youtube.go new file mode 100644 index 0000000..095194d --- /dev/null +++ b/youtube.go @@ -0,0 +1,167 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net/url" + "regexp" + "strings" + + "github.com/Jeffail/gabs" + "github.com/parnurzeal/gorequest" +) + +// Logic borrowed and refactored from http://github.com/kkdai/youtube + +type stream map[string]string +type youtube struct { + streamList []stream + videoID string + videoInfo string +} + +func (y *youtube) parseVideoInfo() error { + answer, err := url.ParseQuery(y.videoInfo) + if err != nil { + return err + } + + status, ok := answer["status"] + if !ok { + err = fmt.Errorf("no response status found in the server's answer") + return err + } + if status[0] == "fail" { + reason, ok := answer["reason"] + if ok { + err = fmt.Errorf("'fail' response status found in the server's answer, reason: '%s'", reason[0]) + } else { + err = errors.New(fmt.Sprint("'fail' response status found in the server's answer, no reason given")) + } + return err + } + if status[0] != "ok" { + err = fmt.Errorf("non-success response status found in the server's answer (status: '%s')", status) + return err + } + + // read the streams map + streamMap, ok := answer["url_encoded_fmt_stream_map"] + if !ok { + err = errors.New(fmt.Sprint("no stream map found in the server's answer")) + return err + } + + // read each stream + for streamPos, streamRaw := range strings.Split(streamMap[0], ",") { + streamQry, err := url.ParseQuery(streamRaw) + if err != nil { + log.Println(fmt.Sprintf("An error occured while decoding one of the video's stream's information: stream %d: %s\n", streamPos, err)) + continue + } + + stream := stream{ + "quality": streamQry["quality"][0], + "type": streamQry["type"][0], + "url": streamQry["url"][0], + "sig": "", + "title": answer["title"][0], + "author": answer["author"][0], + } + if _, exist := streamQry["sig"]; exist { + stream["sig"] = streamQry["sig"][0] + } + + y.streamList = append(y.streamList, stream) + } + return nil +} + +func (y *youtube) getVideoInfo() error { + url := "http://youtube.com/get_video_info?video_id=" + y.videoID + _, body, err := gorequest.New().Get(url).End() + if err != nil { + return err[0] + } + y.videoInfo = body + return nil +} + +func (y *youtube) findVideoID(videoID string) error { + if strings.Contains(videoID, "youtu") || strings.ContainsAny(videoID, "\"?&/<%=") { + reList := []*regexp.Regexp{ + regexp.MustCompile(`(?:v|embed|watch\?v)(?:=|/)([^"&?/=%]{11})`), + regexp.MustCompile(`(?:=|/)([^"&?/=%]{11})`), + regexp.MustCompile(`([^"&?/=%]{11})`), + } + for _, re := range reList { + if isMatch := re.MatchString(videoID); isMatch { + subs := re.FindStringSubmatch(videoID) + videoID = subs[1] + break + } + } + } + + y.videoID = videoID + fmt.Println(videoID) + if strings.ContainsAny(videoID, "?&/<%=") { + return errors.New("invalid characters in video id") + } + if len(videoID) < 10 { + return errors.New("the video id must be at least 10 characters long") + } + return nil +} + +func searchYoutube(text string) (string, error) { + formattedURL := fmt.Sprintf("https://www.googleapis.com/youtube/v3/search?part=snippet&q=%s&key=%s", url.QueryEscape(text), *googleKey) + + _, body, err := gorequest.New().Get(formattedURL).EndBytes() + if err != nil { + fmt.Println(err) + } + + jsonParsed, _ := gabs.ParseJSON(body) + children, _ := jsonParsed.S("items").Children() + if len(children) == 0 { + return "", fmt.Errorf("No video found") + } + + videoID, _ := children[0].Path("id.videoId").Data().(string) + return videoID, nil +} + +// GetYoutubeURL converts a standard Youtube URL or ID to an mp4 download link, +// or searches Youtube and picks the first result. +func GetYoutubeURL(query string) (string, string, error) { + y := new(youtube) + + if len(query) < 4 || query[:4] != "http" { + link, err := searchYoutube(query) + if err != nil { + return "", "", err + } + query = link + } + + err := y.findVideoID(query) + if err != nil { + return "", "", fmt.Errorf("findVideoID error=%s", err) + } + + err = y.getVideoInfo() + if err != nil { + return "", "", fmt.Errorf("getVideoInfo error=%s", err) + } + + err = y.parseVideoInfo() + if err != nil { + return "", "", fmt.Errorf("parse video info failed, err=%s", err) + } + + targetStream := y.streamList[0] + downloadURL := targetStream["url"] + "&signature=" + targetStream["sig"] + return downloadURL, targetStream["title"], nil +}