DEV Community

Cover image for Building a Twitch.tv Chat Bot with Go - Part 2
Forest Hoffman
Forest Hoffman

Posted on • Edited on

Building a Twitch.tv Chat Bot with Go - Part 2

EDIT (5/13/2019): Since the time of writing the package has been renamed from "twitchbot" to "bot". However, the tagged releases used for this tutorial still use the old name.

All of the disclaimers and warnings at the beginning of Part 1 apply here.

Step 1

Go ahead and jump to Step 1 via the following:

$ git reset --hard step-1
Enter fullscreen mode Exit fullscreen mode

In the previous step we laid out the data types that the BasicBot struct would need to function properly. Now, we'll lay out the behavior for the bot. To do that we'll start by filling out the TwitchBot interface.

From the notes just below the import block, we need to make functions that do the following:

  • Connect to the Twitch IRC server
  • Disconnect from the Twitch IRC server
  • Parse and react to messages from the chat
  • Join a specific channel once connected
  • Read from the super-top-secret credentials file to get the bot's password
  • Send messages to the current chat channel

So, in Go terms:

  • Connect()
  • Disconnect()
  • HandleChat() error
  • JoinChannel()
  • ReadCredentials() (*OAuthCred, error)
  • Say(msg string) error

The ReadCredentials() function looks funny, because the OAuthCred pointer being returned is a mistake. Don't worry, it gets fixed in the following steps. I thought about omitting it for the sake of the walkthrough, but it's bound to make anyone following along think, "Huh?", while turning their head sideways.

We'll also need a function to kick everything off, so the Start() function will be added. Pop open twitchbot.go and make the following changes to the TwitchBot interface:

type TwitchBot interface {
    Connect()
    Disconnect()
    HandleChat() error
    JoinChannel()
    ReadCredentials() (*OAuthCred, error)
    Say(msg string) error
    Start()
}
Enter fullscreen mode Exit fullscreen mode

Since OAuthCred is undefined, we'll add that too. Let's add it above TwitchBot.

type OAuthCred struct {
    Password string `json:"password,omitempty"`
}

type TwitchBot interface {
...
Enter fullscreen mode Exit fullscreen mode

Now that OAuthCred has been defined, let's add a pointer to an OAuthCred instance to the BasicBot struct, for later. The BasicBot definition should now look like:

type BasicBot struct {
    Channel string
    Credentials *OAuthCred // add the pointer field
    MsgRate time.Duration
    Name string
    Port string
    PrivatePath string
    Server string
}
Enter fullscreen mode Exit fullscreen mode

Go ahead and build now, if there are no explosions we can move on.

$ go fmt ./... && go build
Enter fullscreen mode Exit fullscreen mode

Step 2

Let's run the following to jump to Step 2:

$ git reset --hard step-2
Enter fullscreen mode Exit fullscreen mode

Now, we have to make the BasicBot implement the functions that we added to the TwitchBot interface. Let's start go in order.

Connect()

BasicBot.Connect() is going to require the net and fmt packages, so we'll add those to the import block:

import (
    "fmt"
    "net"
    ...
)
Enter fullscreen mode Exit fullscreen mode

Since we want to see what the bot is doing when it connects, we'll need to write to standard output. Unfortunately that can get a bit unwieldy, so lets add in some helper functions to timestamp the log messages. We'll also add a constant time-format string for timeStamp() to use, just after the import block.

import (
...
)

const PSTFormat = "Jan 2 15:04:05 PST"

...

// for BasicBot
func timeStamp() string {
    return TimeStamp(PSTFormat)
}

// the generic variation, for bots using the TwitchBot interface
func TimeStamp(format string) string {
    return time.Now().Format(format)
}
Enter fullscreen mode Exit fullscreen mode

And, then the function itself:

// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
// succeeds or is manually shutdown.
func (bb *BasicBot) Connect() {
    var err error
    fmt.Printf("[%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 {
        fmt.Printf("[%s] Cannot connect to %s, retrying.\n", timeStamp(), bb.Server)
        bb.Connect()
        return
    }
    fmt.Printf("[%s] Connected to %s!\n", timeStamp(), bb.Server)
    bb.startTime = time.Now()
}
Enter fullscreen mode Exit fullscreen mode

Great, now we've got the BasicBot.Connect() function, and it's set to create a connection to the Twitch IRC server with all the necessary information. However, we don't have a way of storing the connection, like bb.conn assumes within the BasicBot.Connect() function. We're also missing a bb.startTime field to hold the time at which the bot successfully connected to the server. Let's add them:

type BasicBot struct {
    Channel string
    conn net.Conn // add this field
    Credentials *OAuthCred
    MsgRate time.Duration
    Name string
    Port string
    PrivatePath string
    Server string
    startTime time.Time // add this field
}
Enter fullscreen mode Exit fullscreen mode

Disconnect()

// Officially disconnects the bot from the Twitch IRC server.
func (bb *BasicBot) Disconnect() {
    bb.conn.Close()
    upTime := time.Now().Sub(bb.startTime).Seconds()
    fmt.Printf("[%s] Closed connection from %s! | Live for: %fs\n", timeStamp(), bb.Server, upTime)
}
Enter fullscreen mode Exit fullscreen mode

This function does what it says on the tin, and outputs some info on the duration of the bot's connection. Neat!

HandleChat()

BasicBot.HandleChat() is going to be doing the heavy lifting, so there are four packages that are going to be added to the import block:

import (
    "bufio"         // 1
    "errors"        // 2
    "fmt"
    "net"
    "net/textproto" // 3
    "regexp"        // 4
    "time"
)
Enter fullscreen mode Exit fullscreen mode

In order for the bot to understand and react to chat messages, we're going to need to parse the raw messages and use context. That's why we've imported the regexp package. Let's compile some regular expressions for use within the function later. You can add the following under the import block:

// Regex for parsing PRIVMSG strings.
//
// First matched group is the user's name and the second matched group is the content of the
// user's message.
var msgRegex *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+)?`)
Enter fullscreen mode Exit fullscreen mode

You can (and should!) read the Twitch Docs on the various types of IRC messages that get sent, but the gist of it is:

  • there are PING messages that must trigger a PONG response from the bot, otherwise it will be disconnected
  • there are PRIVMSG messages that are sent as a result of someone (including the bot itself) talking in the current chat channel (despite being called "PRIVMSG" messages, they are public to the current chat channel)

The msgRegex variable is going to be used to parse the initial PRIVMSGs and the cmdRegex variable will be used for further parsing to catch bot commands in chat.

Then we'll have the function itself:

// 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 *BasicBot) HandleChat() error {
    fmt.Printf("[%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
        fmt.Printf("[%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 {

            // handle a PRIVMSG message
            matches := msgRegex.FindStringSubmatch(line)
            if nil != matches {
                userName := matches[1]
                msgType := matches[2]

                switch msgType {
                case "PRIVMSG":
                    msg := matches[3]
                    fmt.Printf("[%s] %s: %s\n", timeStamp(), userName, msg)

                    // parse commands from user message
                    cmdMatches := cmdRegex.FindStringSubmatch(msg)
                    if nil != cmdMatches {
                        cmd := cmdMatches[1]
                        //arg := cmdMatches[2]

                        // channel-owner specific commands
                        if userName == bb.Channel {
                            switch cmd {
                            case "tbdown":
                                fmt.Printf(
                                    "[%s] Shutdown command received. Shutting down now...\n",
                                    timeStamp(),
                                )

                                bb.Disconnect()
                                return nil
                            default:
                                // do nothing
                            }
                        }
                    }
                default:
                    // do nothing
                }
            }
        }
        time.Sleep(bb.MsgRate)
    }
}
Enter fullscreen mode Exit fullscreen mode

There's a lot, but the most important bits are where we read from the bot's connection...

...
// reads from connection
tp := textproto.NewReader(bufio.NewReader(bb.conn))
...
Enter fullscreen mode Exit fullscreen mode

...and attempt to keep it alive, by responding to PINGs.

...
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
}
...
Enter fullscreen mode Exit fullscreen mode

The handling of the chat occurs in an infinite loop and has a slight delay to prevent the bot from breaking the message limits, if it were to send chat messages itself.

JoinChannel()

// Makes the bot join its pre-specified channel.
func (bb *BasicBot) JoinChannel() {
    fmt.Printf("[%s] Joining #%s...\n", timeStamp(), bb.Channel)
    bb.conn.Write([]byte("PASS " + bb.Credentials.Password + "\r\n"))
    bb.conn.Write([]byte("NICK " + bb.Name + "\r\n"))
    bb.conn.Write([]byte("JOIN #" + bb.Channel + "\r\n"))

    fmt.Printf("[%s] Joined #%s as @%s!\n", timeStamp(), bb.Channel, bb.Name)
}
Enter fullscreen mode Exit fullscreen mode

BasicBot.JoinChannel() is quite important as it passes along all the necessary information to Twitch, including the bot's password, to create the desired connection.

bb.Credentials.Password will be initialized by the BasicBot.ReadCredentials() function, which is coming up.

ReadCredentials()

In order for BasicBot.ReadCredentials() to do its job, it will need to be able to parse a JSON file at a pre-specified path (stored in the PrivatePath field). So, we'll need to add four more packages to the import block.

import (
    "bufio"
    "encoding/json" // 1
    "errors"
    "fmt"
    "io"            // 2
    "io/ioutil"     // 3
    "net"
    "net/textproto"
    "regexp"
    "strings"       // 4
    "time"
)
Enter fullscreen mode Exit fullscreen mode

Then, the function itself:

// Reads from the private credentials file and stores the data in the bot's Credentials field.
func (bb *BasicBot) ReadCredentials() error {

    // 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
}
Enter fullscreen mode Exit fullscreen mode

Note: An *OAuthCred is no longer being returned. So update the definition for TwitchBot.ReadCredentials():

type TwitchBot interface {
    ...
    ReadCredentials() error
    ...
}
Enter fullscreen mode Exit fullscreen mode

Say()

// Makes the bot send a message to the chat channel.
func (bb *BasicBot) Say(msg string) error {
    if "" == msg {
        return errors.New("BasicBot.Say: msg was empty.")
    }
    _, err := bb.conn.Write([]byte(fmt.Sprintf("PRIVMSG #%s %s\r\n", bb.Channel, msg)))
    if nil != err {
        return err
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Start()

// 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 *BasicBot) Start() {
    err := bb.ReadCredentials()
    if nil != err {
        fmt.Println(err)
        fmt.Println("Aborting...")
        return
    }

    for {
        bb.Connect()
        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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

BasicBot.Start() attempts to initialize the bot's credentials before kicking everything else off.

Go ahead and build now, if there are no explosions you're done!

$ go fmt ./... && go build
Enter fullscreen mode Exit fullscreen mode

BasicBot should now be fully functional.

Step 3/4

$ git reset --hard step-3
Enter fullscreen mode Exit fullscreen mode

Cool, now that we've got a fully functioning bot, let's run it. Unfortunately that can't be done within the twitchbot package since it's just a library. You can however grab my test repo!

$ go get github.com/foresthoffman/twitchbotex
Enter fullscreen mode Exit fullscreen mode

Navigate your terminal into the twitchbotex directory...

Linux/MacOS

$ cd $GOPATH/src/github.com/foresthoffman/twitchbotex
Enter fullscreen mode Exit fullscreen mode

Windows

> cd %GOPATH%\src\github.com\foresthoffman\twitchbotex
Enter fullscreen mode Exit fullscreen mode

...and open the main.go file. You're going to want to replace the Channel and Name values with your own channel usernames. Remember that the Channel value must be lowercase.

Then, from your terminal, you're going to want to add a private/ directory in the twitchbotex package directory.

private/ is where your oauth.json file and the bot's password will be contained. To retrieve this password, you need to connect to Twitch using their OAuth Password Generator. The token will have the following pattern: "oauth:secretsecretsecretsecretsecret".

Once you have the OAuth token for the bot account, you can place it in the oauth.json file containing the following JSON (using your token):

{
    "password": "oauth:secretsecretsecretsecretsecret"
}
Enter fullscreen mode Exit fullscreen mode

This will allow the bot to read in the credentials before attempting to connect to Twitch's servers.

With all that done, you should now be able to build and run the bot:

$ go fmt ./... && go build && ./twitchbotex
Enter fullscreen mode Exit fullscreen mode

Entering !tbdown into your chat, with the bot running, will gently tell the bot to shutdown, per this block from BasicBot.HandleChat():

...
// channel-owner specific commands
if userName == bb.Channel {
    switch cmd {
    case "tbdown":
        fmt.Printf(
            "[%s] Shutdown command received. Shutting down now...\n",
            timeStamp(),
        )

        bb.Disconnect()
        return nil
    default:
        // do nothing
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Pretty Logs

If you'd like to add a dash of color to the bot's log messages, you can switch back to the twitchbot package really quick and run:

$ git reset --hard step-4
Enter fullscreen mode Exit fullscreen mode

Then rebuild and run twitchbotex, and the logs will be a little easier on the eyes.

An image comparison of the console without and with colored text.

Thank you for reading! If you have any questions you can hit me up on Twitter (@forestjhoffman) or email (forestjhoffman@gmail.com).

Image Attribution

Top comments (0)