2017-01-05 23:08:29 +01:00
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"io"
2017-01-16 20:55:41 +01:00
"io/ioutil"
2017-01-05 23:08:29 +01:00
"math/rand"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"text/tabwriter"
"time"
log "github.com/Sirupsen/logrus"
"github.com/bwmarrin/discordgo"
2017-01-07 22:44:28 +01:00
humanize "github.com/dustin/go-humanize"
2017-01-05 23:08:29 +01:00
)
var (
// discordgo session
discord * discordgo . Session
// Map of Guild id's to *Play channels, used for queuing and rate-limiting guilds
2017-01-06 16:48:11 +01:00
queues = make ( map [ string ] chan * Play )
2017-01-05 23:08:29 +01:00
2017-01-06 16:48:11 +01:00
// bitrate Sound encoding settings
bitrate = 128
// maxQueueSize Sound encoding settings
maxQueueSize = 6
2017-01-05 23:08:29 +01:00
2017-01-06 16:48:11 +01:00
// OWNER variable
2017-01-05 23:08:29 +01:00
OWNER string
)
// Play represents an individual use of the !airhorn command
type Play struct {
GuildID string
ChannelID string
UserID string
Sound * Sound
// The next play to occur after this, only used for chaining sounds like anotha
Next * Play
// If true, this was a forced play using a specific airhorn sound name
Forced bool
}
2017-01-06 16:48:11 +01:00
// SoundCollection of Sounds
2017-01-05 23:08:29 +01:00
type SoundCollection struct {
Prefix string
Commands [] string
Sounds [] * Sound
ChainWith * SoundCollection
soundRange int
}
// Sound represents a sound clip
type Sound struct {
Name string
// Weight adjust how likely it is this song will play, higher = more likely
Weight int
// Delay (in milliseconds) for the bot to wait before sending the disconnect request
PartDelay int
// Buffer to store encoded PCM packets
buffer [][] byte
}
2017-01-16 20:55:41 +01:00
// COLLECTIONS all collections
var COLLECTIONS [] * SoundCollection
// Create collections
func createCollections () {
files , _ := ioutil . ReadDir ( "./audio" )
for _ , f := range files {
2017-01-18 23:44:32 +01:00
if strings . Contains ( f . Name (), ".dca" ) {
soundfile := strings . Split ( strings . Replace ( f . Name (), ".dca" , "" , - 1 ), "_" )
containsPrefix := false
containsSound := false
2017-01-16 20:55:41 +01:00
2017-01-18 23:44:32 +01:00
if len ( COLLECTIONS ) == 0 {
addNewSoundCollection ( soundfile [ 0 ], soundfile [ 1 ])
}
for _ , c := range COLLECTIONS {
if c . Prefix == soundfile [ 0 ] {
containsPrefix = true
for _ , sound := range c . Sounds {
if sound . Name == soundfile [ 1 ] {
containsSound = true
}
}
if ! containsSound {
c . Sounds = append ( c . Sounds , createSound ( soundfile [ 1 ], 1 , 250 ))
2017-01-16 20:55:41 +01:00
}
}
}
2017-01-18 23:44:32 +01:00
if ! containsPrefix {
addNewSoundCollection ( soundfile [ 0 ], soundfile [ 1 ])
}
2017-01-16 20:55:41 +01:00
}
}
2017-01-05 23:08:29 +01:00
}
2017-01-16 20:55:41 +01:00
func addNewSoundCollection ( prefix string , soundname string ) {
var SC = & SoundCollection {
Prefix : prefix ,
Commands : [] string {
"!" + prefix ,
},
Sounds : [] * Sound {
createSound ( soundname , 1 , 250 ),
},
}
COLLECTIONS = append ( COLLECTIONS , SC )
2017-01-05 23:08:29 +01:00
}
// Create a Sound struct
func createSound ( Name string , Weight int , PartDelay int ) * Sound {
return & Sound {
Name : Name ,
Weight : Weight ,
PartDelay : PartDelay ,
buffer : make ([][] byte , 0 ),
}
}
2017-01-06 16:48:11 +01:00
// Load soundcollection
2017-01-05 23:08:29 +01:00
func ( sc * SoundCollection ) Load () {
for _ , sound := range sc . Sounds {
sc . soundRange += sound . Weight
sound . Load ( sc )
}
}
2017-01-06 16:48:11 +01:00
// Random select sound
func ( sc * SoundCollection ) Random () * Sound {
2017-01-05 23:08:29 +01:00
var (
i int
2017-01-06 16:48:11 +01:00
number = randomRange ( 0 , sc . soundRange )
2017-01-05 23:08:29 +01:00
)
2017-01-06 16:48:11 +01:00
for _ , sound := range sc . Sounds {
2017-01-05 23:08:29 +01:00
i += sound . Weight
if number < i {
return sound
}
}
return nil
}
// Load attempts to load an encoded sound file from disk
// DCA files are pre-computed sound files that are easy to send to Discord.
// If you would like to create your own DCA files, please use:
// https://github.com/nstafie/dca-rs
// eg: dca-rs --raw -i <input wav file> > <output file>
func ( s * Sound ) Load ( c * SoundCollection ) error {
path := fmt . Sprintf ( "audio/%v_%v.dca" , c . Prefix , s . Name )
file , err := os . Open ( path )
if err != nil {
fmt . Println ( "error opening dca file :" , err )
return err
}
var opuslen int16
for {
// read opus frame length from dca file
err = binary . Read ( file , binary . LittleEndian , & opuslen )
// If this is the end of the file, just return
if err == io . EOF || err == io . ErrUnexpectedEOF {
return nil
}
if err != nil {
fmt . Println ( "error reading from dca file :" , err )
return err
}
// read encoded pcm from dca file
InBuf := make ([] byte , opuslen )
err = binary . Read ( file , binary . LittleEndian , & InBuf )
// Should not be any end of file errors
if err != nil {
fmt . Println ( "error reading from dca file :" , err )
return err
}
// append encoded pcm data to the buffer
s . buffer = append ( s . buffer , InBuf )
}
}
2017-01-06 16:48:11 +01:00
// Play plays this sound over the specified VoiceConnection
2017-01-05 23:08:29 +01:00
func ( s * Sound ) Play ( vc * discordgo . VoiceConnection ) {
vc . Speaking ( true )
defer vc . Speaking ( false )
for _ , buff := range s . buffer {
vc . OpusSend <- buff
}
}
// Attempts to find the current users voice channel inside a given guild
func getCurrentVoiceChannel ( user * discordgo . User , guild * discordgo . Guild ) * discordgo . Channel {
for _ , vs := range guild . VoiceStates {
if vs . UserID == user . ID {
channel , _ := discord . State . Channel ( vs . ChannelID )
return channel
}
}
return nil
}
// Returns a random integer between min and max
func randomRange ( min , max int ) int {
rand . Seed ( time . Now (). UTC (). UnixNano ())
return rand . Intn ( max - min ) + min
}
// Prepares a play
func createPlay ( user * discordgo . User , guild * discordgo . Guild , coll * SoundCollection , sound * Sound ) * Play {
// Grab the users voice channel
channel := getCurrentVoiceChannel ( user , guild )
if channel == nil {
log . WithFields ( log . Fields {
"user" : user . ID ,
"guild" : guild . ID ,
}). Warning ( "Failed to find channel to play sound in" )
return nil
}
// Create the play
play := & Play {
GuildID : guild . ID ,
ChannelID : channel . ID ,
UserID : user . ID ,
Sound : sound ,
Forced : true ,
}
// If we didn't get passed a manual sound, generate a random one
if play . Sound == nil {
play . Sound = coll . Random ()
play . Forced = false
}
// If the collection is a chained one, set the next sound
if coll . ChainWith != nil {
play . Next = & Play {
GuildID : play . GuildID ,
ChannelID : play . ChannelID ,
UserID : play . UserID ,
Sound : coll . ChainWith . Random (),
Forced : play . Forced ,
}
}
return play
}
// Prepares and enqueues a play into the ratelimit/buffer guild queue
func enqueuePlay ( user * discordgo . User , guild * discordgo . Guild , coll * SoundCollection , sound * Sound ) {
play := createPlay ( user , guild , coll , sound )
if play == nil {
return
}
// Check if we already have a connection to this guild
// yes, this isn't threadsafe, but its "OK" 99% of the time
_ , exists := queues [ guild . ID ]
if exists {
2017-01-06 16:48:11 +01:00
if len ( queues [ guild . ID ]) < maxQueueSize {
2017-01-05 23:08:29 +01:00
queues [ guild . ID ] <- play
}
} else {
2017-01-06 16:48:11 +01:00
queues [ guild . ID ] = make ( chan * Play , maxQueueSize )
2017-01-05 23:08:29 +01:00
playSound ( play , nil )
}
}
// Play a sound
func playSound ( play * Play , vc * discordgo . VoiceConnection ) ( err error ) {
log . WithFields ( log . Fields {
"play" : play ,
}). Info ( "Playing sound" )
if vc == nil {
vc , err = discord . ChannelVoiceJoin ( play . GuildID , play . ChannelID , false , false )
// vc.Receive = false
if err != nil {
log . WithFields ( log . Fields {
"error" : err ,
}). Error ( "Failed to play sound" )
delete ( queues , play . GuildID )
return err
}
}
// If we need to change channels, do that now
if vc . ChannelID != play . ChannelID {
vc . ChangeChannel ( play . ChannelID , false , false )
time . Sleep ( time . Millisecond * 125 )
}
// Sleep for a specified amount of time before playing the sound
time . Sleep ( time . Millisecond * 32 )
// Play the sound
play . Sound . Play ( vc )
// If this is chained, play the chained sound
if play . Next != nil {
playSound ( play . Next , vc )
}
// If there is another song in the queue, recurse and play that
if len ( queues [ play . GuildID ]) > 0 {
2017-01-06 16:48:11 +01:00
play = <- queues [ play . GuildID ]
2017-01-05 23:08:29 +01:00
playSound ( play , vc )
return nil
}
// If the queue is empty, delete it
time . Sleep ( time . Millisecond * time . Duration ( play . Sound . PartDelay ))
delete ( queues , play . GuildID )
vc . Disconnect ()
return nil
}
func onReady ( s * discordgo . Session , event * discordgo . Ready ) {
2017-01-06 16:48:11 +01:00
log . Info ( "Received READY payload." )
2017-01-05 23:08:29 +01:00
}
func scontains ( key string , options ... string ) bool {
for _ , item := range options {
if item == key {
return true
}
}
return false
}
func displayBotStats ( cid string ) {
stats := runtime . MemStats {}
runtime . ReadMemStats ( & stats )
users := 0
for _ , guild := range discord . State . Ready . Guilds {
users += len ( guild . Members )
}
w := & tabwriter . Writer {}
buf := & bytes . Buffer {}
w . Init ( buf , 0 , 4 , 0 , ' ' , 0 )
fmt . Fprintf ( w , "```\n" )
fmt . Fprintf ( w , "Discordgo: \t%s\n" , discordgo . VERSION )
fmt . Fprintf ( w , "Go: \t%s\n" , runtime . Version ())
fmt . Fprintf ( w , "Memory: \t%s / %s (%s total allocated)\n" , humanize . Bytes ( stats . Alloc ), humanize . Bytes ( stats . Sys ), humanize . Bytes ( stats . TotalAlloc ))
fmt . Fprintf ( w , "Tasks: \t%d\n" , runtime . NumGoroutine ())
fmt . Fprintf ( w , "Servers: \t%d\n" , len ( discord . State . Ready . Guilds ))
fmt . Fprintf ( w , "Users: \t%d\n" , users )
fmt . Fprintf ( w , "```\n" )
w . Flush ()
discord . ChannelMessageSend ( cid , buf . String ())
}
func utilGetMentioned ( s * discordgo . Session , m * discordgo . MessageCreate ) * discordgo . User {
for _ , mention := range m . Mentions {
if mention . ID != s . State . Ready . User . ID {
return mention
}
}
return nil
}
// Handles bot operator messages, should be refactored (lmao)
func handleBotControlMessages ( s * discordgo . Session , m * discordgo . MessageCreate , parts [] string , g * discordgo . Guild ) {
if scontains ( parts [ 1 ], "status" ) {
displayBotStats ( m . ChannelID )
}
}
2017-01-11 21:07:37 +01:00
func setIdleStatus () {
2017-01-07 22:37:47 +01:00
games := [] string {
"Terranigma" ,
"Secret of Mana" ,
"Quake III Arena" ,
"Duke Nukem 3D" ,
"Monkey Island 2: LeChuck's Revenge" ,
"Turtles in Time" ,
"Unreal Tournament" ,
"Half-Life" ,
"Half-Life 2" ,
"Warcraft II" ,
"Starcraft" ,
"Diablo" ,
"Diablo II" ,
"A Link to the Past" ,
"Ocarina of Time" ,
"Star Fox" ,
"Tetris" ,
"Pokémon Red" ,
"Pokémon Blue" ,
"Die Siedler II" ,
"Day of the Tentacle" ,
"Maniac Mansion" ,
"Prince of Persia" ,
"Super Mario Kart" ,
"Pac-Man" ,
"Frogger" ,
"Donkey Kong" ,
"Donkey Kong Country" ,
"Asteroids" ,
"Doom" ,
"Breakout" ,
"Street Fighter II" ,
"Wolfenstein 3D" ,
"Mega Man" ,
"Myst" ,
"R-Type" ,
}
2017-01-06 19:10:55 +01:00
for {
2017-01-11 21:07:37 +01:00
discord . UpdateStreamingStatus ( 1 , "" , "" )
discord . UpdateStatus ( 0 , games [ randomRange ( 0 , len ( games ))])
2017-01-06 23:52:43 +01:00
time . Sleep ( time . Duration ( randomRange ( 5 , 15 )) * time . Minute )
2017-01-06 19:10:55 +01:00
}
2017-01-06 18:44:59 +01:00
}
2017-01-05 23:08:29 +01:00
func onMessageCreate ( s * discordgo . Session , m * discordgo . MessageCreate ) {
2017-01-05 23:18:00 +01:00
if m . Content == "ping" || m . Content == "pong" {
// If the message is "ping" reply with "Pong!"
if m . Content == "ping" {
2017-01-06 16:48:11 +01:00
s . ChannelMessageSend ( m . ChannelID , "Pong!" )
2017-01-05 23:18:00 +01:00
}
2017-01-05 23:08:29 +01:00
2017-01-05 23:18:00 +01:00
// If the message is "pong" reply with "Ping!"
if m . Content == "pong" {
2017-01-06 16:48:11 +01:00
s . ChannelMessageSend ( m . ChannelID , "Ping!" )
2017-01-05 23:18:00 +01:00
}
// Updating bot status
s . UpdateStatus ( 0 , "Ping Pong with " + m . Author . Username )
}
2017-01-05 23:08:29 +01:00
if len ( m . Content ) <= 0 || ( m . Content [ 0 ] != '!' && len ( m . Mentions ) < 1 ) {
return
}
2017-01-16 20:55:41 +01:00
if m . Content == "!list" {
var list string
for _ , c := range COLLECTIONS {
list += "**!" + c . Prefix + "**\n"
for _ , sounds := range c . Sounds {
list += sounds . Name + "\n"
}
list += "\n"
}
2017-01-16 21:09:49 +01:00
st , _ := s . UserChannelCreate ( m . Author . ID )
s . ChannelMessageSend ( st . ID , list )
2017-01-16 20:55:41 +01:00
go deleteCommandMessage ( s , m . ChannelID , m . ID )
}
2017-01-05 23:08:29 +01:00
msg := strings . Replace ( m . ContentWithMentionsReplaced (), s . State . Ready . User . Username , "username" , 1 )
parts := strings . Split ( strings . ToLower ( msg ), " " )
channel , _ := discord . State . Channel ( m . ChannelID )
if channel == nil {
log . WithFields ( log . Fields {
"channel" : m . ChannelID ,
"message" : m . ID ,
}). Warning ( "Failed to grab channel" )
return
}
guild , _ := discord . State . Guild ( channel . GuildID )
if guild == nil {
log . WithFields ( log . Fields {
"guild" : channel . GuildID ,
"channel" : channel ,
"message" : m . ID ,
}). Warning ( "Failed to grab guild" )
return
}
// If this is a mention, it should come from the owner (otherwise we don't care)
if len ( m . Mentions ) > 0 && m . Author . ID == OWNER && len ( parts ) > 0 {
mentioned := false
for _ , mention := range m . Mentions {
mentioned = ( mention . ID == s . State . Ready . User . ID )
if mentioned {
break
}
}
if mentioned {
handleBotControlMessages ( s , m , parts , guild )
}
return
}
// Find the collection for the command we got
for _ , coll := range COLLECTIONS {
if scontains ( parts [ 0 ], coll . Commands ... ) {
2017-01-09 21:34:37 +01:00
go deleteCommandMessage ( s , m . ChannelID , m . ID )
2017-01-05 23:08:29 +01:00
// If they passed a specific sound effect, find and select that (otherwise play nothing)
var sound * Sound
if len ( parts ) > 1 {
for _ , s := range coll . Sounds {
if parts [ 1 ] == s . Name {
sound = s
}
}
if sound == nil {
return
}
}
go enqueuePlay ( m . Author , guild , coll , sound )
return
}
}
}
2017-01-08 14:57:26 +01:00
// Delete the message after a delay so the channel does not get cluttered
func deleteCommandMessage ( s * discordgo . Session , channelID string , messageID string ) {
time . Sleep ( 30 * time . Second )
err := s . ChannelMessageDelete ( channelID , messageID )
if err != nil {
log . WithFields ( log . Fields {
"error" : err ,
}). Error ( "Failed to delete message." )
}
}
2017-01-05 23:08:29 +01:00
func main () {
var (
2017-01-08 20:27:51 +01:00
Token = flag . String ( "t" , "" , "Discord Authentication Token" )
Shard = flag . String ( "s" , "" , "Shard ID" )
ShardCount = flag . String ( "c" , "" , "Number of shards" )
Owner = flag . String ( "o" , "" , "Owner ID" )
Port = flag . Int ( "p" , 0 , "Web server port" )
RedirectURL = flag . String ( "r" , "" , "Address where the web server will be available without slash at the end. For example: \"http://bot.example.org:12345\"" )
Ci = flag . Int ( "ci" , 0 , "ClientID" )
Cs = flag . String ( "cs" , "" , "ClientSecret" )
err error
2017-01-05 23:08:29 +01:00
)
flag . Parse ()
2017-01-16 20:55:41 +01:00
// create SoundCollections by scanning the audio folder
createCollections ()
2017-01-08 20:15:09 +01:00
// Start Webserver if a valid port is provided and if ClientID and ClientSecret are set
2017-01-08 20:27:51 +01:00
if * Port != 0 && * Port >= 1 && * Ci != 0 && * Cs != "" && * RedirectURL != "" {
2017-01-08 20:15:09 +01:00
log . Infoln ( "Starting web server on port " + strconv . Itoa ( * Port ))
2017-01-08 20:27:51 +01:00
go startWebServer ( strconv . Itoa ( * Port ), strconv . Itoa ( * Ci ), * Cs , * RedirectURL )
2017-01-08 20:15:09 +01:00
} else {
log . Infoln ( "Required web server arguments missing or invalid. Skipping web server start." )
}
2017-01-05 23:08:29 +01:00
if * Owner != "" {
OWNER = * Owner
}
// Preload all the sounds
log . Info ( "Preloading sounds..." )
for _ , coll := range COLLECTIONS {
coll . Load ()
}
// Create a discord session
log . Info ( "Starting discord session..." )
2017-01-06 16:48:11 +01:00
discord , err = discordgo . New ( "Bot " + * Token )
2017-01-05 23:08:29 +01:00
if err != nil {
log . WithFields ( log . Fields {
"error" : err ,
}). Fatal ( "Failed to create discord session" )
return
}
// Set sharding info
discord . ShardID , _ = strconv . Atoi ( * Shard )
discord . ShardCount , _ = strconv . Atoi ( * ShardCount )
if discord . ShardCount <= 0 {
discord . ShardCount = 1
}
discord . AddHandler ( onReady )
discord . AddHandler ( onMessageCreate )
err = discord . Open ()
if err != nil {
log . WithFields ( log . Fields {
"error" : err ,
}). Fatal ( "Failed to create discord websocket connection" )
return
}
2017-01-11 21:07:37 +01:00
go setIdleStatus ()
2017-01-05 23:08:29 +01:00
// We're running!
2017-01-06 16:48:11 +01:00
log . Info ( "Gidbig is ready. Quit with CTRL-C." )
2017-01-05 23:08:29 +01:00
// Wait for a signal to quit
c := make ( chan os . Signal , 1 )
signal . Notify ( c , os . Interrupt , os . Kill )
<- c
}