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
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()
}
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 {
...
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
}
Go ahead and build now, if there are no explosions we can move on.
$ go fmt ./... && go build
Step 2
Let's run the following to jump to Step 2:
$ git reset --hard step-2
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"
...
)
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)
}
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()
}
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
}
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)
}
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"
)
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+)?`)
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)
}
}
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))
...
...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
}
...
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)
}
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"
)
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
}
Note: An *OAuthCred
is no longer being returned. So update the definition for TwitchBot.ReadCredentials()
:
type TwitchBot interface {
...
ReadCredentials() error
...
}
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
}
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
}
}
}
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
BasicBot should now be fully functional.
Step 3/4
$ git reset --hard step-3
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
Navigate your terminal into the twitchbotex directory...
Linux/MacOS
$ cd $GOPATH/src/github.com/foresthoffman/twitchbotex
Windows
> cd %GOPATH%\src\github.com\foresthoffman\twitchbotex
...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"
}
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
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
}
}
...
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
Then rebuild and run twitchbotex
, and the logs will be a little easier on the eyes.
Thank you for reading! If you have any questions you can hit me up on Twitter (@forestjhoffman) or email (forestjhoffman@gmail.com).
Image Attribution
- Photo by Caspar Camille Rubin on Unsplash
- Glitch, Copyright of Twitch.tv
Top comments (0)