2020-02-13 15:18:44 +00:00
|
|
|
package irc
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"encoding/json"
|
2020-02-21 19:54:27 +00:00
|
|
|
"encoding/base64"
|
2020-02-13 15:18:44 +00:00
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"math/rand"
|
|
|
|
"net"
|
|
|
|
"net/textproto"
|
|
|
|
"regexp"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
rgb "github.com/foresthoffman/rgblog"
|
2020-02-21 19:54:27 +00:00
|
|
|
scribble "github.com/nanobox-io/golang-scribble"
|
|
|
|
uuid "github.com/google/uuid"
|
2020-02-13 15:18:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
const PSTFormat = "Jan 2 15:04:05 PST"
|
|
|
|
|
|
|
|
// Regex for parsing PRIVMSG strings.
|
|
|
|
//
|
2020-02-14 14:56:10 +00:00
|
|
|
// First matched group is the user's name, second is the channel? and the third matched group is the content of the
|
2020-02-13 15:18:44 +00:00
|
|
|
// user's message.
|
2020-02-14 14:56:10 +00:00
|
|
|
var MsgRegex *regexp.Regexp = regexp.MustCompile(`^:(\w+)!\w+@\w+\.tmi\.twitch\.tv (PRIVMSG) #(\w+)(?: :(.*))?$`)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
// 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 OAuthCred struct {
|
|
|
|
|
|
|
|
// The bot account's OAuth password.
|
|
|
|
Password string `json:"password,omitempty"`
|
|
|
|
|
|
|
|
// The developer application client ID. Used for API calls to Twitch.
|
|
|
|
ClientID string `json:"client_id,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type KardBot struct {
|
|
|
|
Channel string
|
|
|
|
conn net.Conn
|
|
|
|
Credentials *OAuthCred
|
|
|
|
MsgRate time.Duration
|
|
|
|
Name string
|
|
|
|
Port string
|
|
|
|
PrivatePath string
|
|
|
|
Server string
|
|
|
|
startTime time.Time
|
2020-02-13 15:39:57 +00:00
|
|
|
Prompts []string
|
2020-02-21 19:54:27 +00:00
|
|
|
Database scribble.Driver
|
2020-02-22 13:05:30 +00:00
|
|
|
ChannelData map[string]ChannelData
|
2020-02-21 19:54:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type ChannelData struct {
|
|
|
|
Name string `json:"name"`
|
|
|
|
AdminKey string `json:"value,omitempty"`
|
2020-02-22 13:05:30 +00:00
|
|
|
Command string `json:"customcommand,omitempty"`
|
2020-02-21 19:54:27 +00:00
|
|
|
ExtraStrings string `json:"extrastrings,omitempty"`
|
|
|
|
JoinTime time.Time `json:"jointime"`
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
|
|
|
|
// succeeds or is forcefully shutdown.
|
2020-02-13 15:39:57 +00:00
|
|
|
func (bb *KardBot) Connect() {
|
2020-02-13 15:18:44 +00:00
|
|
|
var err error
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.YPrintf("[%s] Connecting to %s...\n", TimeStamp(), bb.Server)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
// makes connection to Twitch IRC server
|
|
|
|
bb.conn, err = net.Dial("tcp", bb.Server+":"+bb.Port)
|
|
|
|
if nil != err {
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.YPrintf("[%s] Cannot connect to %s, retrying.\n", TimeStamp(), bb.Server)
|
2020-02-13 15:18:44 +00:00
|
|
|
bb.Connect()
|
|
|
|
return
|
|
|
|
}
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.YPrintf("[%s] Connected to %s!\n", TimeStamp(), bb.Server)
|
2020-02-13 15:18:44 +00:00
|
|
|
bb.startTime = time.Now()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Officially disconnects the bot from the Twitch IRC server.
|
2020-02-13 15:39:57 +00:00
|
|
|
func (bb *KardBot) Disconnect() {
|
2020-02-13 15:18:44 +00:00
|
|
|
bb.conn.Close()
|
|
|
|
upTime := time.Now().Sub(bb.startTime).Seconds()
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.YPrintf("[%s] Closed connection from %s! | Live for: %fs\n", TimeStamp(), bb.Server, upTime)
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
2020-02-13 15:39:57 +00:00
|
|
|
func (bb *KardBot) HandleChat() error {
|
|
|
|
rgb.YPrintf("[%s] Watching #%s...\n", TimeStamp(), bb.Channel)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
// 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
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.YPrintf("[%s] %s\n", TimeStamp(), line)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
|
|
// handle a PRIVMSG message
|
|
|
|
matches := MsgRegex.FindStringSubmatch(line)
|
|
|
|
if nil != matches {
|
|
|
|
userName := matches[1]
|
|
|
|
msgType := matches[2]
|
2020-02-14 14:56:10 +00:00
|
|
|
channel := matches[3]
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
switch msgType {
|
|
|
|
case "PRIVMSG":
|
2020-02-14 14:56:10 +00:00
|
|
|
msg := matches[4]
|
2020-02-13 15:39:57 +00:00
|
|
|
rgb.GPrintf("[%s] %s: %s\n", TimeStamp(), userName, msg)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
// parse commands from user message
|
|
|
|
cmdMatches := CmdRegex.FindStringSubmatch(msg)
|
|
|
|
if nil != cmdMatches {
|
|
|
|
cmd := cmdMatches[1]
|
|
|
|
|
2020-02-22 13:05:30 +00:00
|
|
|
rgb.YPrintf("[%s] Checking cmd %s against %s\n", TimeStamp(), cmd, bb.ChannelData[channel].Command)
|
2020-02-13 15:18:44 +00:00
|
|
|
switch cmd {
|
2020-02-22 13:05:30 +00:00
|
|
|
case bb.ChannelData[channel].Command:
|
2020-02-14 14:56:10 +00:00
|
|
|
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
|
2020-02-13 15:18:44 +00:00
|
|
|
|
2020-02-14 14:56:10 +00:00
|
|
|
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// channel-owner specific commands
|
2020-02-21 19:54:27 +00:00
|
|
|
if userName == channel {
|
2020-02-13 15:18:44 +00:00
|
|
|
switch cmd {
|
|
|
|
case "tbdown":
|
|
|
|
rgb.CPrintf(
|
|
|
|
"[%s] Shutdown command received. Shutting down now...\n",
|
2020-02-13 15:39:57 +00:00
|
|
|
TimeStamp(),
|
2020-02-13 15:18:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
bb.Disconnect()
|
|
|
|
return nil
|
2020-02-21 19:54:27 +00:00
|
|
|
case "wat":
|
|
|
|
magicCode := bb.readOrCreateChannelKey(channel)
|
|
|
|
rgb.CPrintf(
|
|
|
|
"[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n",
|
|
|
|
TimeStamp(),
|
|
|
|
magicCode, userName, magicCode,
|
|
|
|
)
|
|
|
|
bb.Say("Ack.")
|
2020-02-13 15:18:44 +00:00
|
|
|
default:
|
|
|
|
// do nothing
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
// do nothing
|
2020-02-14 14:56:10 +00:00
|
|
|
rgb.YPrintf("[%s] unknown IRC message : %s\n", TimeStamp(), line)
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
time.Sleep(bb.MsgRate)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-14 14:56:10 +00:00
|
|
|
// Login to the IRC server
|
|
|
|
func (bb *KardBot) Login() {
|
|
|
|
rgb.YPrintf("[%s] Logging into #%s...\n", TimeStamp(), bb.Channel)
|
2020-02-13 15:18:44 +00:00
|
|
|
bb.conn.Write([]byte("PASS " + bb.Credentials.Password + "\r\n"))
|
|
|
|
bb.conn.Write([]byte("NICK " + bb.Name + "\r\n"))
|
2020-02-14 14:56:10 +00:00
|
|
|
}
|
|
|
|
|
2020-02-22 13:05:30 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-14 14:56:10 +00:00
|
|
|
// Makes the bot join its pre-specified channel.
|
|
|
|
func (bb *KardBot) JoinChannel(channels ...string) {
|
|
|
|
if len(channels) == 0 {
|
|
|
|
channels = append(channels, bb.Channel)
|
|
|
|
}
|
2020-02-13 15:18:44 +00:00
|
|
|
|
2020-02-14 14:56:10 +00:00
|
|
|
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)
|
|
|
|
}
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Reads from the private credentials file and stores the data in the bot's Credentials field.
|
2020-02-13 15:39:57 +00:00
|
|
|
func (bb *KardBot) ReadCredentials() error {
|
2020-02-13 15:18:44 +00:00
|
|
|
|
|
|
|
// reads from the file
|
|
|
|
credFile, err := ioutil.ReadFile(bb.PrivatePath)
|
|
|
|
if nil != err {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
bb.Credentials = &OAuthCred{}
|
|
|
|
|
|
|
|
// parses the file contents
|
|
|
|
dec := json.NewDecoder(strings.NewReader(string(credFile)))
|
|
|
|
if err = dec.Decode(bb.Credentials); nil != err && io.EOF != err {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Makes the bot send a message to the chat channel.
|
2020-02-14 14:56:10 +00:00
|
|
|
func (bb *KardBot) Say(msg string, channels ...string) error {
|
2020-02-13 15:18:44 +00:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
|
2020-02-14 14:56:10 +00:00
|
|
|
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
|
|
|
|
}
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
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.
|
2020-02-13 15:39:57 +00:00
|
|
|
func (bb *KardBot) Start() {
|
2020-02-13 15:18:44 +00:00
|
|
|
err := bb.ReadCredentials()
|
|
|
|
if nil != err {
|
|
|
|
fmt.Println(err)
|
2020-02-21 19:54:27 +00:00
|
|
|
fmt.Println("Aborting!")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
err = bb.readChannelData()
|
|
|
|
if nil != err {
|
|
|
|
fmt.Println(err)
|
|
|
|
fmt.Println("Aborting!")
|
2020-02-13 15:18:44 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
for {
|
|
|
|
bb.Connect()
|
2020-02-14 14:56:10 +00:00
|
|
|
bb.Login()
|
2020-02-22 13:05:30 +00:00
|
|
|
if len(bb.ChannelData) > 0 {
|
|
|
|
for channelName := range(bb.ChannelData) {
|
2020-02-21 19:54:27 +00:00
|
|
|
bb.JoinChannel(channelName)
|
|
|
|
}
|
2020-02-14 14:56:10 +00:00
|
|
|
} else {
|
|
|
|
bb.JoinChannel()
|
|
|
|
}
|
2020-02-13 15:18:44 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-21 19:54:27 +00:00
|
|
|
func (bb *KardBot) readChannelData() error {
|
|
|
|
records, err := bb.Database.ReadAll("channelData")
|
|
|
|
if err != nil {
|
|
|
|
// no db? initialise one?
|
2020-02-22 13:05:30 +00:00
|
|
|
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
|
|
|
|
rgb.YPrintf("[%s] No channel table for #%s exists, creating...\n", TimeStamp(), bb.Channel)
|
2020-02-21 19:54:27 +00:00
|
|
|
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-02-22 13:05:30 +00:00
|
|
|
bb.ChannelData = make(map[string]ChannelData)
|
|
|
|
bb.ChannelData[bb.Channel] = record;
|
2020-02-21 19:54:27 +00:00
|
|
|
} else {
|
2020-02-22 13:05:30 +00:00
|
|
|
bb.ChannelData = make(map[string]ChannelData)
|
2020-02-21 19:54:27 +00:00
|
|
|
}
|
|
|
|
for _, data := range records {
|
|
|
|
record := ChannelData{}
|
|
|
|
err := json.Unmarshal([]byte(data), &record);
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-02-22 13:05:30 +00:00
|
|
|
if record.Name != "" {
|
|
|
|
if record.Command == "" {
|
|
|
|
record.Command = "card"
|
|
|
|
|
|
|
|
rgb.YPrintf("[%s] Rewriting data for #%s...\n", TimeStamp(), bb.Channel)
|
|
|
|
if err := bb.Database.Write("channelData", record.Name, record); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
bb.ChannelData[record.Name] = record
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Managed to leave the main channel!?
|
|
|
|
if bb.ChannelData[bb.Channel].Name == "" {
|
|
|
|
rgb.YPrintf("[%s] No channel data for #%s exists, creating...\n", TimeStamp(), bb.Channel)
|
|
|
|
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
|
|
|
|
bb.ChannelData[bb.Channel] = record
|
|
|
|
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
records, err = bb.Database.ReadAll("channelData")
|
2020-02-21 19:54:27 +00:00
|
|
|
}
|
2020-02-22 13:05:30 +00:00
|
|
|
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(bb.ChannelData))
|
2020-02-21 19:54:27 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (bb *KardBot) readOrCreateChannelKey(channel string) string {
|
|
|
|
magicCode := ""
|
|
|
|
var err error
|
|
|
|
var record ChannelData
|
2020-02-22 13:05:30 +00:00
|
|
|
if record, ok := bb.ChannelData[channel]; !ok {
|
2020-02-21 19:54:27 +00:00
|
|
|
rgb.YPrintf("[%s] No channel data for #%s exists, creating\n", TimeStamp(), channel)
|
|
|
|
err = bb.Database.Read("channelData", channel, &record);
|
|
|
|
if err == nil {
|
2020-02-22 13:05:30 +00:00
|
|
|
bb.ChannelData[channel] = record
|
2020-02-21 19:54:27 +00:00
|
|
|
}
|
|
|
|
}
|
2020-02-22 13:05:30 +00:00
|
|
|
record = bb.ChannelData[channel]
|
2020-02-21 19:54:27 +00:00
|
|
|
if err != nil || record.AdminKey == "" {
|
|
|
|
rgb.YPrintf("[%s] No channel key for #%s exists, creating one\n", TimeStamp(), channel)
|
|
|
|
newuu, _ := uuid.NewRandom()
|
|
|
|
magicCode = base64.StdEncoding.EncodeToString([]byte(newuu.String()))
|
|
|
|
record.AdminKey = magicCode
|
|
|
|
if record.Name == "" {
|
|
|
|
record.Name = channel
|
|
|
|
}
|
|
|
|
if err := bb.Database.Write("channelData", channel, record); err != nil {
|
|
|
|
rgb.RPrintf("[%s] Error writing channel data for #%s\n", TimeStamp(), channel)
|
|
|
|
}
|
2020-02-22 13:05:30 +00:00
|
|
|
bb.ChannelData[record.Name] = record
|
2020-02-21 19:54:27 +00:00
|
|
|
rgb.YPrintf("[%s] Cached channel key for #%s\n", TimeStamp(), record.Name)
|
|
|
|
} else {
|
|
|
|
magicCode = record.AdminKey
|
|
|
|
rgb.YPrintf("[%s] Loaded data for #%s\n", TimeStamp(), channel)
|
|
|
|
}
|
|
|
|
return magicCode
|
|
|
|
}
|
|
|
|
|
2020-02-13 15:39:57 +00:00
|
|
|
func TimeStamp() string {
|
|
|
|
return TimeStampFmt(PSTFormat)
|
2020-02-13 15:18:44 +00:00
|
|
|
}
|
|
|
|
|
2020-02-13 15:39:57 +00:00
|
|
|
func TimeStampFmt(format string) string {
|
2020-02-13 15:18:44 +00:00
|
|
|
return time.Now().Format(format)
|
2020-02-21 19:54:27 +00:00
|
|
|
}
|