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 queues = make(map[string]chan *Play) // bitrate Sound encoding settings bitrate = 128 // maxQueueSize Sound encoding settings maxQueueSize = 6 // OWNER variable 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 } // SoundCollection of Sounds 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 } // AIRHORN Array of all the sounds we have var AIRHORN = &SoundCollection{ 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), }, } // BSTH collection var BSTH = &SoundCollection{ Prefix: "bsth", Commands: []string{ "!bsth", }, Sounds: []*Sound{ createSound("achso", 1, 250), createSound("angeben", 1, 250), createSound("aufwiedersehen", 1, 250), createSound("bluemchen", 1, 250), createSound("blutsprobe", 1, 250), createSound("chefkoch", 1, 250), createSound("danke", 1, 250), createSound("eindummer", 1, 250), createSound("eleganz", 1, 250), createSound("fettesau", 1, 250), createSound("fett", 1, 250), createSound("fressen", 1, 250), createSound("geraeusch", 1, 250), createSound("gottseimitdir", 1, 250), createSound("gruss", 1, 250), createSound("gulli", 1, 250), createSound("harfenkonzert", 1, 250), createSound("hosen", 1, 250), createSound("indiehose", 1, 250), createSound("kai", 1, 250), createSound("kalkleiste", 1, 250), createSound("keinegrauenzellen", 1, 250), createSound("kohlen", 1, 250), createSound("meinspruch", 1, 250), createSound("miete", 1, 250), createSound("nuesse", 1, 250), createSound("ohneheu", 1, 250), createSound("riecher", 1, 250), createSound("sauer", 1, 250), createSound("schatten", 1, 250), createSound("scheitel", 1, 250), createSound("solingen", 1, 250), createSound("unglaublich", 1, 250), createSound("unmoeglich", 1, 250), createSound("vortreten", 1, 250), createSound("zufaellig", 1, 250), createSound("arschloch", 1, 250), createSound("aufstossen", 1, 250), createSound("ausholen", 1, 250), createSound("blaehungen", 1, 250), createSound("haesslich", 1, 250), createSound("horchlappen", 1, 250), createSound("interesseng", 1, 250), createSound("keingrips", 1, 250), createSound("kriegicheineis", 1, 250), createSound("lachespaeter", 1, 250), createSound("machtjanix", 1, 250), createSound("nichtsofett", 1, 250), createSound("schweine", 1, 250), createSound("unfreundlich", 1, 250), createSound("verheiratet", 1, 250), createSound("zulaut", 1, 250), }, } // BSTH2 collection var BSTH2 = &SoundCollection{ Prefix: "bsth2", Commands: []string{ "!bsth2", }, Sounds: []*Sound{ createSound("abendplanung", 1, 250), createSound("alleine", 1, 250), createSound("chefkoch", 1, 250), createSound("fett", 1, 250), createSound("hubschrauber", 1, 250), createSound("perspektive", 1, 250), createSound("platonisch", 1, 250), createSound("qualle", 1, 250), }, } // KHALED collection var KHALED = &SoundCollection{ Prefix: "another", ChainWith: AIRHORN, Commands: []string{ "!anotha", "!anothaone", }, Sounds: []*Sound{ createSound("one", 1, 250), createSound("one_classic", 1, 250), createSound("one_echo", 1, 250), }, } // CENA collection var CENA = &SoundCollection{ 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), }, } // ETHAN collection var ETHAN = &SoundCollection{ 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), }, } // COW collection var COW = &SoundCollection{ Prefix: "cow", Commands: []string{ "!stan", "!stanislav", }, Sounds: []*Sound{ createSound("herd", 10, 250), createSound("moo", 10, 250), createSound("x3", 1, 250), }, } // BIRTHDAY collection var BIRTHDAY = &SoundCollection{ Prefix: "birthday", Commands: []string{ "!birthday", "!bday", }, Sounds: []*Sound{ createSound("horn", 50, 250), createSound("horn3", 30, 250), createSound("sadhorn", 25, 250), createSound("weakhorn", 25, 250), }, } // WOW collection var WOW = &SoundCollection{ Prefix: "wow", Commands: []string{ "!wowthatscool", "!wtc", }, Sounds: []*Sound{ createSound("thatscool", 50, 250), }, } // COLLECTIONS all collections var COLLECTIONS = []*SoundCollection{ AIRHORN, KHALED, CENA, ETHAN, COW, BIRTHDAY, WOW, BSTH, BSTH2, } // 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), } } // Load soundcollection func (sc *SoundCollection) Load() { for _, sound := range sc.Sounds { sc.soundRange += sound.Weight sound.Load(sc) } } // Random select sound func (sc *SoundCollection) Random() *Sound { var ( i int number = randomRange(0, sc.soundRange) ) for _, sound := range sc.Sounds { 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 > 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) } } // Play plays this sound over the specified VoiceConnection 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 { if len(queues[guild.ID]) < maxQueueSize { queues[guild.ID] <- play } } else { queues[guild.ID] = make(chan *Play, maxQueueSize) 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 { play = <-queues[play.GuildID] 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) { log.Info("Received READY payload.") } 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]) } } func setIdleStatus(s *discordgo.Session) { games := []string{"Terranigma", "Secret of Mana", "Quake III Arena", "Duke Nukem 3D", "Monkey Island 2: LeChuck's Revenge", "TMNT: Turtles in Time", "Unreal Tournament", "Half-Life", "Half-Life 2", "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 { s.UpdateStreamingStatus(1, "", "") s.UpdateStatus(0, games[randomRange(0, len(games))]) time.Sleep(time.Duration(randomRange(5, 15)) * time.Minute) } } func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Content == "ping" || m.Content == "pong" { // If the message is "ping" reply with "Pong!" if m.Content == "ping" { s.ChannelMessageSend(m.ChannelID, "Pong!") } // If the message is "pong" reply with "Ping!" if m.Content == "pong" { s.ChannelMessageSend(m.ChannelID, "Ping!") } // Updating bot status s.UpdateStatus(0, "Ping Pong with "+m.Author.Username) } 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...") discord, err = discordgo.New("Bot " + *Token) 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 } go setIdleStatus(discord) // We're running! log.Info("Gidbig is ready. Quit with CTRL-C.") // Wait for a signal to quit c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, os.Kill) <-c }