First iteration. Basically almost only copy paste for now.

This commit is contained in:
da
2017-01-05 23:08:29 +01:00
parent dd3f896017
commit 0069c88484
3 changed files with 755 additions and 3 deletions
+4 -2
View File
@@ -1,5 +1,7 @@
MIT License
Copyright (c) <year> <copyright holders>
The MIT License (MIT)
Copyright (c) 2016 Hammer and Chisel
Copyright (c) 2017 Daniel Aberger
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:
+2 -1
View File
@@ -1,3 +1,4 @@
# Gidbig
Gidbig is a Discord Bot in Go.
Gidbig is a Discord Bot in Go based on
[Airhornbot by Hammer and Chisel](https://github.com/hammerandchisel/airhornbot/). Visit [https://airhorn.solutions/](https://airhorn.solutions/).
+749
View File
@@ -0,0 +1,749 @@
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"
redis "gopkg.in/redis.v3"
)
var (
// discordgo session
discord *discordgo.Session
// Redis client connection (used for stats)
rcli *redis.Client
// Map of Guild id's to *Play channels, used for queuing and rate-limiting guilds
queues map[string]chan *Play = make(map[string]chan *Play)
// Sound encoding settings
BITRATE = 128
MAX_QUEUE_SIZE = 6
// Owner
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
}
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
}
// Array of all the sounds we have
var AIRHORN *SoundCollection = &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),
},
}
var KHALED *SoundCollection = &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),
},
}
var CENA *SoundCollection = &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),
},
}
var ETHAN *SoundCollection = &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),
},
}
var COW *SoundCollection = &SoundCollection{
Prefix: "cow",
Commands: []string{
"!stan",
"!stanislav",
},
Sounds: []*Sound{
createSound("herd", 10, 250),
createSound("moo", 10, 250),
createSound("x3", 1, 250),
},
}
var BIRTHDAY *SoundCollection = &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),
},
}
var WOW *SoundCollection = &SoundCollection{
Prefix: "wow",
Commands: []string{
"!wowthatscool",
"!wtc",
},
Sounds: []*Sound{
createSound("thatscool", 50, 250),
},
}
var COLLECTIONS []*SoundCollection = []*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),
}
}
func (sc *SoundCollection) Load() {
for _, sound := range sc.Sounds {
sc.soundRange += sound.Weight
sound.Load(sc)
}
}
func (s *SoundCollection) Random() *Sound {
var (
i int
number int = randomRange(0, s.soundRange)
)
for _, sound := range s.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)
}
}
// 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]) < MAX_QUEUE_SIZE {
queues[guild.ID] <- play
}
} else {
queues[guild.ID] = make(chan *Play, MAX_QUEUE_SIZE)
playSound(play, nil)
}
}
func trackSoundStats(play *Play) {
if rcli == nil {
return
}
_, err := rcli.Pipelined(func(pipe *redis.Pipeline) error {
var baseChar string
if play.Forced {
baseChar = "f"
} else {
baseChar = "a"
}
base := fmt.Sprintf("airhorn:%s", baseChar)
pipe.Incr("airhorn:total")
pipe.Incr(fmt.Sprintf("%s:total", base))
pipe.Incr(fmt.Sprintf("%s:sound:%s", base, play.Sound.Name))
pipe.Incr(fmt.Sprintf("%s:user:%s:sound:%s", base, play.UserID, play.Sound.Name))
pipe.Incr(fmt.Sprintf("%s:guild:%s:sound:%s", base, play.GuildID, play.Sound.Name))
pipe.Incr(fmt.Sprintf("%s:guild:%s:chan:%s:sound:%s", base, play.GuildID, play.ChannelID, play.Sound.Name))
pipe.SAdd(fmt.Sprintf("%s:users", base), play.UserID)
pipe.SAdd(fmt.Sprintf("%s:guilds", base), play.GuildID)
pipe.SAdd(fmt.Sprintf("%s:channels", base), play.ChannelID)
return nil
})
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Warning("Failed to track stats in redis")
}
}
// 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)
}
// Track stats for this play in redis
go trackSoundStats(play)
// 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("Recieved READY payload")
s.UpdateStatus(0, "airhornbot.com")
}
func onGuildCreate(s *discordgo.Session, event *discordgo.GuildCreate) {
/*
if event.Guild.Unavailable != nil {
return
}
for _, channel := range event.Guild.Channels {
if channel.ID == event.Guild.ID {
s.ChannelMessageSend(channel.ID, "**AIRHORN BOT READY FOR HORNING. TYPE `!AIRHORN` WHILE IN A VOICE CHANNEL TO ACTIVATE**")
return
}
}
*/
}
func scontains(key string, options ...string) bool {
for _, item := range options {
if item == key {
return true
}
}
return false
}
func calculateAirhornsPerSecond(cid string) {
current, _ := strconv.Atoi(rcli.Get("airhorn:a:total").Val())
time.Sleep(time.Second * 10)
latest, _ := strconv.Atoi(rcli.Get("airhorn:a:total").Val())
discord.ChannelMessageSend(cid, fmt.Sprintf("Current APS: %v", (float64(latest-current))/10.0))
}
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 utilSumRedisKeys(keys []string) int {
results := make([]*redis.StringCmd, 0)
rcli.Pipelined(func(pipe *redis.Pipeline) error {
for _, key := range keys {
results = append(results, pipe.Get(key))
}
return nil
})
var total int
for _, i := range results {
t, _ := strconv.Atoi(i.Val())
total += t
}
return total
}
func displayUserStats(cid, uid string) {
keys, err := rcli.Keys(fmt.Sprintf("airhorn:*:user:%s:sound:*", uid)).Result()
if err != nil {
return
}
totalAirhorns := utilSumRedisKeys(keys)
discord.ChannelMessageSend(cid, fmt.Sprintf("Total Airhorns: %v", totalAirhorns))
}
func displayServerStats(cid, sid string) {
keys, err := rcli.Keys(fmt.Sprintf("airhorn:*:guild:%s:sound:*", sid)).Result()
if err != nil {
return
}
totalAirhorns := utilSumRedisKeys(keys)
discord.ChannelMessageSend(cid, fmt.Sprintf("Total Airhorns: %v", totalAirhorns))
}
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], "stats") {
if len(m.Mentions) >= 2 {
displayUserStats(m.ChannelID, utilGetMentioned(s, m).ID)
} else if len(parts) >= 3 {
displayUserStats(m.ChannelID, parts[2])
} else {
displayServerStats(m.ChannelID, g.ID)
}
} else if scontains(parts[1], "bomb") && len(parts) >= 4 {
airhornBomb(m.ChannelID, g, utilGetMentioned(s, m), parts[3])
} else if scontains(parts[1], "aps") {
s.ChannelMessageSend(m.ChannelID, ":ok_hand: give me a sec m8")
go calculateAirhornsPerSecond(m.ChannelID)
}
}
func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
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")
Redis = flag.String("r", "", "Redis Connection String")
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()
}
// If we got passed a redis server, try to connect
if *Redis != "" {
log.Info("Connecting to redis...")
rcli = redis.NewClient(&redis.Options{Addr: *Redis, DB: 0})
_, err = rcli.Ping().Result()
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Fatal("Failed to connect to redis")
return
}
}
// Create a discord session
log.Info("Starting discord session...")
discord, err = discordgo.New(*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!
log.Info("AIRHORNBOT is ready to horn it up.")
// Wait for a signal to quit
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
<-c
}