Browse Source

Sorta works

Signed-off-by: Martyn Ranyard <>
Martyn 6 months ago
6 changed files with 602 additions and 0 deletions
  1. +12
  2. +12
  3. +81
  4. BIN
  5. +491
  6. +6

+ 12
- 0
Dockerfile View File

@@ -0,0 +1,12 @@
FROM golang@sha256:cee6f4b901543e8e3f20da3a4f7caac6ea643fd5a46201c3c2387183a332d989 AS builder
RUN apk update && apk add --no-cache git make ca-certificates && update-ca-certificates
COPY main.go /go/src/
RUN cd /go/src/; go get; CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o karaokards .
#RUN ls /go/src/ -l
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /go/src/ /app/
COPY strings.json /app/strings.json
CMD ["/app/karaokards"]

+ 12
- 0
configmap.yaml View File

@@ -0,0 +1,12 @@
apiVersion: v1
strings.json: "{\r\n \"strings\": [\r\n\t \"Let Pineboy choose!\",\r\n\t \"They're
from the North\",\r\n \"Refers to food\"\r\n ]\r\n}"
kind: ConfigMap
creationTimestamp: "2020-01-27T20:04:58Z"
name: extracards
namespace: karaokards
resourceVersion: "48537355"
selfLink: /api/v1/namespaces/karaokards/configmaps/extracards
uid: 4b1d73b0-4140-11ea-94d8-9cb6540931b5

+ 81
- 0
deploy.yaml View File

@@ -0,0 +1,81 @@
apiVersion: extensions/v1beta1
kind: Deployment
annotations: "1"
creationTimestamp: "2020-01-27T19:50:47Z"
generation: 3
run: kardbot
name: kardbot
namespace: karaokards
resourceVersion: "48537399"
selfLink: /apis/extensions/v1beta1/namespaces/karaokards/deployments/kardbot
uid: 502b4760-413e-11ea-94d8-9cb6540931b5
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 10
run: kardbot
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
creationTimestamp: null
run: kardbot
- image: imartyn/karokardbot:0.0.1
imagePullPolicy: IfNotPresent
name: kardbot
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
- mountPath: /etc/twitch/
name: oauth
- mountPath: /app/strings.json
name: extracards
subPath: strings.json
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
- name: oauth
defaultMode: 420
secretName: twitchoauth
- configMap:
defaultMode: 420
- key: strings.json
path: strings.json
name: extracards
name: extracards
availableReplicas: 1
- lastTransitionTime: "2020-01-27T19:50:47Z"
lastUpdateTime: "2020-01-27T20:05:34Z"
message: ReplicaSet "kardbot-6dff8d86dd" has successfully progressed.
reason: NewReplicaSetAvailable
status: "True"
type: Progressing
- lastTransitionTime: "2020-01-27T20:08:26Z"
lastUpdateTime: "2020-01-27T20:08:26Z"
message: Deployment has minimum availability.
reason: MinimumReplicasAvailable
status: "True"
type: Available
observedGeneration: 3
readyReplicas: 1
replicas: 1
updatedReplicas: 1

karaokards View File

+ 491
- 0
main.go View File

@@ -0,0 +1,491 @@
package main
import (
rgb ""
var karaokards = [...]string {
"Chorus contains a name",
"Refers to an animal",
"Contains instructions",
"Refers to relationships",
"Contains made-up words",
"Refers to a colour",
"Chorus contains me, mine or my",
"Chorus contains want, need or have",
"Chorus contains how, when or why",
"Refers to money",
"Chorus contains love, like or hate",
"Chorus contains man, woman or everybody",
"Refers to explosives",
"Chorus contains a number",
"Love Song",
"This is a morality tale",
"I've never sung this before",
"They'd beat me in a fight",
"My mortal enemy",
"Their name starts with the same letter as mine",
"This is NOT my era",
"Chorus contains oh, ooh or baby",
"Samples another song",
"Chorus contains don't, won't or can't",
"Title contains day, night or tomorrow",
"Chorus contains boy, girl or child",
"Refers to space",
"Title contains brackets",
"Refers to religion",
"Refers to music",
"Chorus contains this, that or there",
"Chorus contains up, down or over",
"Mean about someone",
"Refers to weather",
"Chorus contains you, your or you're",
"Contains questions",
"Refers to death",
"Refers to sleep",
"Chorus contains heart, head or soul",
"This is their Second-best song",
"I shouldn't know this song. But I do!",
"I don't need the screen!",
"I'm too old for this song",
"The story of my life",
"I love this song SO much",
"They're twice my age!",
"Chorus contains I, I’m or I’ve",
"Title is one word long",
"Is a metaphor",
"Title is at least five words long",
"Chorus contains move, stay or go",
"Refers to a place",
"Good workout music",
"Requires audience participation",
"Power Ballad",
"My secret shame",
"This gets me a bit emotional",
"Out of my range",
"This person is bad at their job",
"They look like someone here",
"I don't know the verse",
"They're half my age!",
"Play this at my funeral",
"This is NOT my genre",
"I hate this song so much",
"Girl band",
"Male solo",
"Artist begins with A",
"Just one word",
"Mixed gender band",
"Artist begins with C",
"Artist begins with G",
"Two people",
"TV contestant",
"Artist begins with P",
"Artist begins with M",
"In a famous family",
"Male-fronted band",
"One-hit wonder",
"Female-fronted band",
"Artist begins with T",
"I was a teenager!",
"The music video is so good",
"I’m younger than this song",
"First dance at my imaginary wedding",
"I'm worried about them",
"This person is the best dancer",
"I know the dance",
"Mostly shouting",
"They're not my gender",
"I wish we were married",
"I played this song too often",
"They are so influential",
"My favourite song of theirs",
"Perfect montage music",
"Artist uses their surname",
"Famous partner",
"Artist begins with E",
"Troubled artist",
"Artist begins with F",
"Solo artist",
"Artist begins with R",
"Artist begins with ‘The’",
"10+ year career",
"Boy band",
"Female solo",
"Inappropriately clothed",
"Award winners",
"Artist begins with S",
"Artist begins with W",
"They split up :(",
"North American",
"Goth or Emo",
"This is a bit creepy, frankly",
"I’ve seen them in real life",
"A beautiful love story",
"OK, this is just ridiculous",
"Artist begins with B",
"Artist begins with D",
"They’re dead :(",
"Artist begins with L",
"Amazing hair",
"Artist begins with J"}
var selectablePrompts []string
const PSTFormat = "Jan 2 15:04:05 PST"
// 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+)?`)
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
// 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)
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() {
upTime := time.Now().Sub(bb.startTime).Seconds()
rgb.YPrintf("[%s] Closed connection from %s! | Live for: %fs\n", timeStamp(), bb.Server, upTime)
// 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
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" == line {
// respond to PING message with a PONG message, to maintain the connection
} 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]
rgb.GPrintf("[%s] %s: %s\n", timeStamp(), userName, msg)
// parse commands from user message
cmdMatches := CmdRegex.FindStringSubmatch(msg)
if nil != cmdMatches {
cmd := cmdMatches[1]
switch cmd {
case "card":
rgb.CPrintf("[%s] Card asked for!\n", timeStamp(),)
bb.Say("Your prompt is : "+selectablePrompts[rand.Intn(len(selectablePrompts))])
// channel-owner specific commands
if userName == bb.Channel {
switch cmd {
case "tbdown":
"[%s] Shutdown command received. Shutting down now...\n",
return nil
// do nothing
// do nothing
// Makes the bot join its pre-specified channel.
func (bb *kardBot) JoinChannel() {
rgb.YPrintf("[%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"))
rgb.YPrintf("[%s] Joined #%s as @%s!\n", timeStamp(), bb.Channel, bb.Name)
// Reads from the private credentials file and stores the data in the bot's Credentials field.
func (bb *kardBot) 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
// Makes the bot send a message to the chat channel.
func (bb *kardBot) Say(msg 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")
_, err := bb.conn.Write([]byte(fmt.Sprintf("PRIVMSG #%s :%s\r\n", bb.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()
if nil != err {
for {
err = bb.HandleChat()
if nil != err {
// attempts to reconnect upon unexpected chat error
time.Sleep(1000 * time.Millisecond)
fmt.Println("Starting bot again...")
} else {
func timeStamp() string {
return TimeStamp(PSTFormat)
func TimeStamp(format string) string {
return time.Now().Format(format)
type customStringsStruct struct {
Strings []string `json:"strings,omitempty"`
var customStrings customStringsStruct;
func readBonusStrings() []string {
ex, err := os.Executable()
if err != nil {
return []string{}
fmt.Println("Could not read `strings.json`, will only have builtin prompts. File reading error", err)
exPath := filepath.Dir(ex)
data, err := ioutil.ReadFile(exPath+"/strings.json")
if err != nil {
fmt.Println("Could not read `strings.json`, will only have builtin prompts. File reading error", err)
return []string{}
err = json.Unmarshal(data, &customStrings);
if err != nil {
fmt.Println("Could not unmarshal `strings.json`, will only have builtin prompts. Unmarshal error", err)
return []string{}
fmt.Println("Read ",len(customStrings.Strings)," prompts from `strings.json`");
return customStrings.Strings
func main() {
for _, val := range karaokards {
selectablePrompts = append(selectablePrompts,val);
for _, val := range readBonusStrings() {
selectablePrompts = append(selectablePrompts,val);
fmt.Println(len(selectablePrompts)," prompts available.");
oauthPath := ""
if (os.Getenv("TWITCH_OAUTH_JSON") != "") {
if _, err := os.Stat(os.Getenv("TWITCH_OAUTH_JSON")); os.IsNotExist(err) {
oauthPath = os.Getenv("TWITCH_OAUTH_JSON")
} else {
if _, err := os.Stat(os.Getenv("HOME")+"/.twitch/oauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Warning %s doesn't exist, trying %s next!\n", timeStamp(), os.Getenv("HOME")+"/.twitch/oauth.json", "/etc/twitch/oauth.json");
if _, err := os.Stat("/etc/twitch/oauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error %s doesn't exist either, bailing!\n", timeStamp(), "/etc/twitch/oauth.json");
oauthPath = "/etc/twitch/oauth.json";
} else {
oauthPath = os.Getenv("HOME")+"/.twitch/oauth.json";
// Replace the channel name, bot name, and the path to the private directory with your respective
// values.
myBot := kardBot{
Channel: "imartynontwitch",
MsgRate: time.Duration(20/30) * time.Millisecond,
Name: "Karaokards",
Port: "6667",
PrivatePath: oauthPath,
Server: "",

+ 6
- 0
strings.json View File

@@ -0,0 +1,6 @@
"strings": [
"They're from the North",
"Refers to food"