diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..b64d442 --- /dev/null +++ b/Dockerfile @@ -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/github.com/iMartyn/karaokards/ +RUN cd /go/src/github.com/iMartyn/karaokards/; go get; CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o karaokards . +#RUN ls /go/src/github.com/karaokards/ -l + + +FROM scratch +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /go/src/github.com/iMartyn/karaokards/karaokards /app/ +COPY strings.json /app/strings.json +CMD ["/app/karaokards"] \ No newline at end of file diff --git a/configmap.yaml b/configmap.yaml new file mode 100644 index 0000000..6ac7f75 --- /dev/null +++ b/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +data: + 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 +metadata: + 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 diff --git a/deploy.yaml b/deploy.yaml new file mode 100644 index 0000000..69b9e8b --- /dev/null +++ b/deploy.yaml @@ -0,0 +1,81 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2020-01-27T19:50:47Z" + generation: 3 + labels: + run: kardbot + name: kardbot + namespace: karaokards + resourceVersion: "48537399" + selfLink: /apis/extensions/v1beta1/namespaces/karaokards/deployments/kardbot + uid: 502b4760-413e-11ea-94d8-9cb6540931b5 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + run: kardbot + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + run: kardbot + spec: + containers: + - image: imartyn/karokardbot:0.0.1 + imagePullPolicy: IfNotPresent + name: kardbot + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /etc/twitch/ + name: oauth + - mountPath: /app/strings.json + name: extracards + subPath: strings.json + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 + volumes: + - name: oauth + secret: + defaultMode: 420 + secretName: twitchoauth + - configMap: + defaultMode: 420 + items: + - key: strings.json + path: strings.json + name: extracards + name: extracards +status: + availableReplicas: 1 + conditions: + - 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 diff --git a/karaokards b/karaokards new file mode 100755 index 0000000..352f0f1 Binary files /dev/null and b/karaokards differ diff --git a/main.go b/main.go new file mode 100755 index 0000000..3b7a91b --- /dev/null +++ b/main.go @@ -0,0 +1,491 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/textproto" + "regexp" + "strings" + "time" + "math/rand" + "os" + "path/filepath" + + rgb "github.com/foresthoffman/rgblog" +) + +var karaokards = [...]string { + "Chorus contains a name", + "Refers to an animal", + "Aggressive", + "Contains instructions", + "Refers to relationships", + "Folk", + "Novelty", + "Pre-sixties", + "1960s", + "Ballad", + "Musical", + "Heartbroken", + "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", + "Anthemic", + "Refers to explosives", + "Chorus contains a number", + "1970s", + "2000s", + "Love Song", + "Country-pop", + "Grunge", + "Indie", + "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", + "Euphoric", + "Title contains day, night or tomorrow", + "Chorus contains boy, girl or child", + "Refers to space", + "Soul", + "R&B", + "Dance", + "Electronie", + "Pop", + "1990s", + "Rock", + "Title contains brackets", + "Refers to religion", + "Refers to music", + "Chorus contains this, that or there", + "Chorus contains up, down or over", + "Beautiful", + "Mean about someone", + "Refers to weather", + "Chorus contains you, your or you're", + "Gloomy", + "Contains questions", + "Refers to death", + "Refers to sleep", + "Chorus contains heart, head or soul", + "Rock", + "Pop", + "Country", + "Punk", + "Rap", + "Christmas", + "Motown", + "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", + "Soundtrack", + "Pop", + "Is a metaphor", + "Title is at least five words long", + "Chorus contains move, stay or go", + "Refers to a place", + "R&B", + "Metal", + "Good workout music", + "Requires audience participation", + "Power Ballad", + "1980s", + "Rock", + "Rap", + "Pop-punk", + "Alternative", + "Soul", + "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", + "European", + "Artist begins with P", + "Artist begins with M", + "In a famous family", + "Male-fronted band", + "Australian", + "One-hit wonder", + "Female-fronted band", + "Artist begins with T", + "Rock", + "Indie-rock", + "R&B", + "Latin", + "Disco", + "Britpop", + "2010s", + "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", + "Actor", + "Artist begins with F", + "Solo artist", + "Artist begins with R", + "Hellraiser", + "Artist begins with ‘The’", + "10+ year career", + "Boy band", + "Female solo", + "British", + "Inappropriately clothed", + "Award winners", + "Artist begins with S", + "Artist begins with W", + "Asian", + "They split up :(", + "North American", + "Pop", + "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) + 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) +} + +// 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 { + + // 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": + rgb.CPrintf( + "[%s] Shutdown command received. Shutting down now...\n", + timeStamp(), + ) + + bb.Disconnect() + return nil + default: + // do nothing + } + } + } + default: + // do nothing + } + } + } + time.Sleep(bb.MsgRate) + } +} + +// 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 { + 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 + } + } +} + +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() { + + rand.Seed(time.Now().UnixNano()) + 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) { + os.Exit(1); + } + 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"); + os.Exit(1); + } + 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: "irc.chat.twitch.tv", + } + myBot.Start() +} \ No newline at end of file diff --git a/strings.json b/strings.json new file mode 100755 index 0000000..d1db7b8 --- /dev/null +++ b/strings.json @@ -0,0 +1,6 @@ +{ + "strings": [ + "They're from the North", + "Refers to food" + ] +} \ No newline at end of file