2017-01-05 23:08:29 +01:00
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"io"
"math/rand"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"text/tabwriter"
"time"
log "github.com/Sirupsen/logrus"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
)
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-06 16:48:11 +01:00
// AIRHORN Array of all the sounds we have
var AIRHORN = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "airhorn" ,
Commands : [] string {
"!airhorn" ,
},
Sounds : [] * Sound {
createSound ( "default" , 1000 , 250 ),
createSound ( "reverb" , 800 , 250 ),
createSound ( "spam" , 800 , 0 ),
createSound ( "tripletap" , 800 , 250 ),
createSound ( "fourtap" , 800 , 250 ),
createSound ( "distant" , 500 , 250 ),
createSound ( "echo" , 500 , 250 ),
createSound ( "clownfull" , 250 , 250 ),
createSound ( "clownshort" , 250 , 250 ),
createSound ( "clownspam" , 250 , 0 ),
createSound ( "highfartlong" , 200 , 250 ),
createSound ( "highfartshort" , 200 , 250 ),
createSound ( "midshort" , 100 , 250 ),
createSound ( "truck" , 10 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// KHALED collection
var KHALED = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "another" ,
ChainWith : AIRHORN ,
Commands : [] string {
"!anotha" ,
"!anothaone" ,
},
Sounds : [] * Sound {
createSound ( "one" , 1 , 250 ),
createSound ( "one_classic" , 1 , 250 ),
createSound ( "one_echo" , 1 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// CENA collection
var CENA = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "jc" ,
Commands : [] string {
"!johncena" ,
"!cena" ,
},
Sounds : [] * Sound {
createSound ( "airhorn" , 1 , 250 ),
createSound ( "echo" , 1 , 250 ),
createSound ( "full" , 1 , 250 ),
createSound ( "jc" , 1 , 250 ),
createSound ( "nameis" , 1 , 250 ),
createSound ( "spam" , 1 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// ETHAN collection
var ETHAN = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "ethan" ,
Commands : [] string {
"!ethan" ,
"!eb" ,
"!ethanbradberry" ,
"!h3h3" ,
},
Sounds : [] * Sound {
createSound ( "areyou_classic" , 100 , 250 ),
createSound ( "areyou_condensed" , 100 , 250 ),
createSound ( "areyou_crazy" , 100 , 250 ),
createSound ( "areyou_ethan" , 100 , 250 ),
createSound ( "classic" , 100 , 250 ),
createSound ( "echo" , 100 , 250 ),
createSound ( "high" , 100 , 250 ),
createSound ( "slowandlow" , 100 , 250 ),
createSound ( "cuts" , 30 , 250 ),
createSound ( "beat" , 30 , 250 ),
createSound ( "sodiepop" , 1 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// COW collection
var COW = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "cow" ,
Commands : [] string {
"!stan" ,
"!stanislav" ,
},
Sounds : [] * Sound {
createSound ( "herd" , 10 , 250 ),
createSound ( "moo" , 10 , 250 ),
createSound ( "x3" , 1 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// BIRTHDAY collection
var BIRTHDAY = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "birthday" ,
Commands : [] string {
"!birthday" ,
"!bday" ,
},
Sounds : [] * Sound {
createSound ( "horn" , 50 , 250 ),
createSound ( "horn3" , 30 , 250 ),
createSound ( "sadhorn" , 25 , 250 ),
createSound ( "weakhorn" , 25 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// WOW collection
var WOW = & SoundCollection {
2017-01-05 23:08:29 +01:00
Prefix : "wow" ,
Commands : [] string {
"!wowthatscool" ,
"!wtc" ,
},
Sounds : [] * Sound {
createSound ( "thatscool" , 50 , 250 ),
},
}
2017-01-06 16:48:11 +01:00
// COLLECTIONS all collections
var COLLECTIONS = [] * SoundCollection {
2017-01-05 23:08:29 +01:00
AIRHORN ,
KHALED ,
CENA ,
ETHAN ,
COW ,
BIRTHDAY ,
WOW ,
}
// 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
}
func airhornBomb ( cid string , guild * discordgo . Guild , user * discordgo . User , cs string ) {
count , _ := strconv . Atoi ( cs )
discord . ChannelMessageSend ( cid , ":ok_hand:" + strings . Repeat ( ":trumpet:" , count ))
// Cap it at something
if count > 100 {
return
}
play := createPlay ( user , guild , AIRHORN , nil )
vc , err := discord . ChannelVoiceJoin ( play . GuildID , play . ChannelID , true , true )
if err != nil {
return
}
for i := 0 ; i < count ; i ++ {
AIRHORN . Random (). Play ( vc )
}
vc . Disconnect ()
}
// 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 )
} else if scontains ( parts [ 1 ], "bomb" ) && len ( parts ) >= 4 {
airhornBomb ( m . ChannelID , g , utilGetMentioned ( s , m ), parts [ 3 ])
}
}
2017-01-06 18:44:59 +01:00
func setIdleStatus ( s * discordgo . Session ) {
2017-01-06 19:10:55 +01:00
games := [] string { "Terranigma" , "Secret of Mana" , "Quake 3 Arena" , "Duke Nukem 3D" , "Monkey Island 2: LeChuck's Revenge" , "Teenage Mutant Ninja Turtles: Turtles in Time" , "Unreal Tournament" , "Half-Life" , "Warcraft II" , "Starcraft" , "Diablo" , "Diablo II" , "The Legend of Zelda: A Link to the Past" , "The Legend of Zelda: 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" }
for {
time . Sleep ( time . Duration ( randomRange ( 10 , 60 )) * time . Minute )
s . UpdateStreamingStatus ( 1 , "" , "" )
s . UpdateStatus ( 0 , games [ randomRange ( 0 , len ( games ))])
}
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
}
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 ... ) {
// 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
}
}
}
func main () {
var (
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" )
err error
)
flag . Parse ()
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-06 19:10:55 +01:00
go setIdleStatus ( discord )
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
}