twitchsingstools/internal/irc/irc.go

398 lines
11 KiB
Go

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)
}