This repository has been archived on 2022-01-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
gidbig/main.go
T

644 lines
14 KiB
Go
Raw Normal View History

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-06 16:48:11 +01:00
// bitrate Sound encoding settings
bitrate = 128
// maxQueueSize Sound encoding settings
maxQueueSize = 6
2017-01-06 16:48:11 +01:00
// 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
}
2017-01-06 16:48:11 +01:00
// 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
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// 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),
},
}
2017-01-06 16:48:11 +01:00
// WOW collection
var WOW = &SoundCollection{
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{
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
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 {
var (
i int
2017-01-06 16:48:11 +01:00
number = randomRange(0, sc.soundRange)
)
2017-01-06 16:48:11 +01:00
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 <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
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 {
queues[guild.ID] <- play
}
} else {
2017-01-06 16:48:11 +01:00
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 {
2017-01-06 16:48:11 +01:00
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) {
2017-01-06 16:48:11 +01:00
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 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: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)
}
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)
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
}
// We're running!
2017-01-06 16:48:11 +01:00
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
}