package irc import ( "bufio" "encoding/json" "errors" "fmt" "io" "io/ioutil" "math/rand" "net" "net/textproto" "regexp" "strings" "time" data "git.martyn.berlin/martyn/twitchsingstools/internal/data" rgb "github.com/foresthoffman/rgblog" ) const UTCFormat = "Jan 2 15:04:05 UTC" // Regex for parsing connection messages // // First matched group is our real username - twitch doesn't complain at using NICK command but doesn't honor it. var ConnectRegex *regexp.Regexp = regexp.MustCompile(`^:tmi.twitch.tv 001 ([^ ]+) .*`) // Regex for parsing PRIVMSG strings. // // First matched group is the user's name, second is the channel? and the third matched group is the content of the // user's message. var MsgRegex *regexp.Regexp = regexp.MustCompile(`^:(\w+)!\w+@\w+\.tmi\.twitch\.tv (PRIVMSG) #(\w+)(?: :(.*))?$`) var DirectMsgRegex *regexp.Regexp = regexp.MustCompile(`^:(\w+)!\w+@\w+\.tmi\.twitch\.tv (PRIVMSG) (\w+)(?: :(.*))?$`) // Regex for parsing user commands, from already parsed PRIVMSG strings. // // First matched group is the command name and the second matched group is the argument for the // command. var CmdRegex *regexp.Regexp = regexp.MustCompile(`^!(\w+)\s?(\w+)?`) type IRCOAuthCred struct { // The bot account's OAuth password. Password string `json:"password,omitempty"` // The developer application client ID. Used for API calls to Twitch. Nick string `json:"nick,omitempty"` } type AppOAuthCred struct { // The bot account's OAuth password. ClientID string `json:"client_id,omitempty"` // The developer application client ID. Used for API calls to Twitch. ClientSecret string `json:"client_secret,omitempty"` } type KardBot struct { Channel string conn net.Conn IrcCredentials *IRCOAuthCred AppCredentials *AppOAuthCred MsgRate time.Duration Name string Port string IrcPrivatePath string AppPrivatePath string Server string startTime time.Time Prompts []string GlobalData data.GlobalData } // Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it // succeeds or is forcefully shutdown. func (bb *KardBot) Connect() { var err error rgb.YPrintf("[%s] Connecting to %s...\n", TimeStamp(), bb.Server) // makes connection to Twitch IRC server bb.conn, err = net.Dial("tcp", bb.Server+":"+bb.Port) if nil != err { rgb.YPrintf("[%s] Cannot connect to %s, retrying.\n", TimeStamp(), bb.Server) bb.Connect() return } rgb.YPrintf("[%s] Connected to %s!\n", TimeStamp(), bb.Server) bb.startTime = time.Now() } // Officially disconnects the bot from the Twitch IRC server. func (bb *KardBot) Disconnect() { bb.conn.Close() upTime := time.Now().Sub(bb.startTime).Seconds() rgb.YPrintf("[%s] Closed connection from %s! | Live for: %fs\n", TimeStamp(), bb.Server, upTime) } // Look at the channels I'm actually in func (bb *KardBot) ActiveChannels() int { count := 0 for _, channel := range bb.GlobalData.ChannelData { if !channel.HasLeft { count = count + 1 } } return count } // Listens for and logs messages from chat. Responds to commands from the channel owner. The bot // continues until it gets disconnected, told to shutdown, or forcefully shutdown. func (bb *KardBot) HandleChat() error { rgb.YPrintf("[%s] Watching #%s...\n", TimeStamp(), bb.Channel) // reads from connection tp := textproto.NewReader(bufio.NewReader(bb.conn)) // listens for chat messages for { line, err := tp.ReadLine() if nil != err { // officially disconnects the bot from the server bb.Disconnect() return errors.New("bb.Bot.HandleChat: Failed to read line from channel. Disconnected.") } // logs the response from the IRC server rgb.YPrintf("[%s] %s\n", TimeStamp(), line) if "PING :tmi.twitch.tv" == line { // respond to PING message with a PONG message, to maintain the connection bb.conn.Write([]byte("PONG :tmi.twitch.tv\r\n")) continue } else { matches := ConnectRegex.FindStringSubmatch(line) if nil != matches { realUserName := matches[1] bb.GlobalData.UpdateJoined(realUserName, false) bb.JoinChannel(realUserName) } matches = DirectMsgRegex.FindStringSubmatch(line) if nil != matches { userName := matches[1] // msgType := matches[2] // channel := matches[3] msg := matches[4] rgb.GPrintf("[%s] Direct message %s: %s\n", TimeStamp(), userName, msg) } // handle a PRIVMSG message matches = MsgRegex.FindStringSubmatch(line) if nil != matches { userName := matches[1] msgType := matches[2] channel := matches[3] switch msgType { case "PRIVMSG": msg := matches[4] rgb.GPrintf("[%s] %s: %s\n", TimeStamp(), userName, msg) rgb.GPrintf("[%s] raw line: %s\n", TimeStamp(), line) // parse commands from user message cmdMatches := CmdRegex.FindStringSubmatch(msg) if nil != cmdMatches { cmd := cmdMatches[1] cardCommand := "" commands := bb.GlobalData.ChannelData[channel].Commands for _, command := range commands { if command.CommandName == data.RandomPrompt { cardCommand = command.KeyWord } } rgb.YPrintf("[%s] Checking cmd %s against [%s]\n", TimeStamp(), cmd, bb.GlobalData.ChannelData[channel].Commands) switch cmd { case cardCommand: if cardCommand != "" { rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel) bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel) } case "join": if bb.GlobalData.ChannelData[channel].ControlChannel { rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel) bb.GlobalData.UpdateJoined(userName, false) bb.JoinChannel(userName) } } // channel-owner specific commands if userName == channel { switch cmd { case "tbdown": rgb.CPrintf( "[%s] Shutdown command received. Shutting down now...\n", TimeStamp(), ) bb.Disconnect() return nil case "kcardadmin": magicCode := bb.GlobalData.ReadOrCreateChannelKey(channel) rgb.CPrintf( "[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n", TimeStamp(), magicCode, userName, magicCode, ) err := bb.Msg("Welcome to Karaokards, your admin panel is https://karaokards.ing.martyn.berlin/admin/"+userName+"/"+magicCode, userName) if err != nil { rgb.RPrintf("[%s] ERROR %s\n", err) } bb.Say("Ack.") default: // do nothing } } } default: // do nothing rgb.YPrintf("[%s] unknown IRC message : %s\n", TimeStamp(), line) } } } time.Sleep(bb.MsgRate) } } // Login to the IRC server func (bb *KardBot) Login() { rgb.YPrintf("[%s] Logging into #%s...\n", TimeStamp(), bb.Channel) bb.conn.Write([]byte("PASS " + bb.IrcCredentials.Password + "\r\n")) bb.conn.Write([]byte("NICK " + bb.Name + "\r\n")) } func (bb *KardBot) LeaveChannel(channels ...string) { for _, channel := range channels { rgb.YPrintf("[%s] Leaving #%s...\n", TimeStamp(), channel) bb.conn.Write([]byte("PART #" + channel + "\r\n")) rgb.YPrintf("[%s] Left #%s as @%s!\n", TimeStamp(), channel, bb.Name) } } // Makes the bot join its pre-specified channel. func (bb *KardBot) JoinChannel(channels ...string) { if len(channels) == 0 { channels = append(channels, bb.Channel) } for _, channel := range channels { rgb.YPrintf("[%s] Joining #%s...\n", TimeStamp(), channel) bb.conn.Write([]byte("JOIN #" + channel + "\r\n")) rgb.YPrintf("[%s] Joined #%s as @%s!\n", TimeStamp(), channel, bb.Name) } } // Reads from the private credentials file and stores the data in the bot's appropriate Credentials field. func (bb *KardBot) ReadCredentials(credType string) error { var err error var credFile []byte // reads from the file if credType == "IRC" { credFile, err = ioutil.ReadFile(bb.IrcPrivatePath) } else { credFile, err = ioutil.ReadFile(bb.AppPrivatePath) } if nil != err { return err } // parses the file contents if credType == "IRC" { var creds IRCOAuthCred dec := json.NewDecoder(strings.NewReader(string(credFile))) if err = dec.Decode(&creds); nil != err && io.EOF != err { return err } bb.IrcCredentials = &creds } else { var creds AppOAuthCred dec := json.NewDecoder(strings.NewReader(string(credFile))) if err = dec.Decode(&creds); nil != err && io.EOF != err { return err } bb.AppCredentials = &creds } return nil } func (bb *KardBot) Msg(msg string, users ...string) error { if "" == msg { return errors.New("BasicBot.Say: msg was empty.") } // check if message is too large for IRC if len(msg) > 512 { return errors.New("BasicBot.Say: msg exceeded 512 bytes") } if len(users) == 0 { return errors.New("BasicBot.Say: users was empty.") } rgb.YPrintf("[%s] sending %s to users %v as @%s!\n", TimeStamp(), msg, users, bb.Name) for _, channel := range users { _, err := bb.conn.Write([]byte(fmt.Sprintf("PRIVMSG %s :%s\r\n", channel, msg))) rgb.YPrintf("[%s] PRIVMSG %s :%s\r\n", TimeStamp(), channel, msg) if nil != err { return err } } return nil } // Makes the bot send a message to the chat channel. func (bb *KardBot) Say(msg string, channels ...string) error { if "" == msg { return errors.New("BasicBot.Say: msg was empty.") } // check if message is too large for IRC if len(msg) > 512 { return errors.New("BasicBot.Say: msg exceeded 512 bytes") } if len(channels) == 0 { channels = append(channels, bb.Channel) } rgb.YPrintf("[%s] sending %s to channels %v as @%s!\n", TimeStamp(), msg, channels, bb.Name) for _, channel := range channels { _, err := bb.conn.Write([]byte(fmt.Sprintf("PRIVMSG #%s :%s\r\n", channel, msg))) rgb.YPrintf("[%s] PRIVMSG #%s :%s\r\n", TimeStamp(), channel, msg) if nil != err { return err } } return nil } // Starts a loop where the bot will attempt to connect to the Twitch IRC server, then connect to the // pre-specified channel, and then handle the chat. It will attempt to reconnect until it is told to // shut down, or is forcefully shutdown. func (bb *KardBot) Start() { err := bb.ReadCredentials("IRC") if nil != err { fmt.Println(err) fmt.Println("Aborting!") return } err = bb.ReadCredentials("App") if nil != err { fmt.Println(err) fmt.Println("Aborting!") return } for { bb.Connect() bb.Login() if len(bb.GlobalData.ChannelData) > 0 { for channelName, channelData := range bb.GlobalData.ChannelData { if !channelData.HasLeft { bb.JoinChannel(channelName) } } } else { bb.JoinChannel() } err = bb.HandleChat() if nil != err { // attempts to reconnect upon unexpected chat error time.Sleep(1000 * time.Millisecond) fmt.Println(err) fmt.Println("Starting bot again...") } else { return } } } func TimeStamp() string { return TimeStampFmt(UTCFormat) } func TimeStampFmt(format string) string { return time.Now().Format(format) }