Compare commits

...

35 Commits

Author SHA1 Message Date
Martyn f7b047f8e3 more oath
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 20:38:07 +02:00
Martyn 7735ca7a27 more oath
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 20:29:19 +02:00
Martyn 0afe049bd7 Oauth is painful
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 19:55:06 +02:00
Martyn 6f1f4d0750 moar debug
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 19:41:12 +02:00
Martyn 90a00f93d1 More debug pls
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 17:30:34 +02:00
Martyn f574863601 gofmt
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-06-30 17:30:05 +02:00
Martyn 34403053ac Add disclaimer
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-03-07 20:51:56 +01:00
Martyn b547819234 Twitch Oauth implemented (by hand)
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-03-07 20:42:16 +01:00
Martyn 8dbe61da21 [not useful] IRC whisper code and join command
continuous-integration/drone/tag Build was killed Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-23 15:32:30 +01:00
Martyn 11c96d20d0 go fmt
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-22 14:08:01 +01:00
Martyn 4803122aad Admin panel with leave option
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-22 14:05:30 +01:00
Martyn 3950f8b672 Merge branch 'improvements' of martyn/karaokards into master
continuous-integration/drone/tag Build was killed Details
2020-02-21 19:56:31 +00:00
Martyn 6e7c52879c database and build date
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 20:55:19 +01:00
Martyn fad3079132 All the magic, database and admin stuff
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 20:54:27 +01:00
Martyn e989b8454b 404s, admin panel route and removal of old data structure
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 20:52:19 +01:00
Martyn 408516ecea External url
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 20:51:17 +01:00
Martyn 4d3b8a33e5 All the deployment updates
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 19:28:11 +01:00
Martyn ba1c9438f3 Makefile for build date
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-21 19:07:08 +01:00
Martyn 06c3b47f47 Oi. windows, no. Yaml not executable.
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-20 19:05:40 +01:00
Martyn 8a9b37cb02 Give the deployment some persistent storage
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-20 18:07:12 +01:00
Martyn a85052a41a Less hard-coding, more "database"
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-15 12:56:17 +01:00
Martyn a9dc6db0a4 Multi-channel support and deployment for webserver.
continuous-integration/drone/tag Build was killed Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-14 15:56:10 +01:00
Martyn Ranyard deb12318b6 Add web server that is not useful 2020-02-14 11:42:38 +01:00
Martyn Ranyard cf5f2e6598 Dockerfile to go standard package location
continuous-integration/drone/tag Build was killed Details
2020-02-14 11:03:25 +01:00
Martyn Ranyard 13b8ce33b5 match https://github.com/golang-standards/project-layout 2020-02-14 10:58:30 +01:00
Martyn Ranyard edc6f08b8b Only attempt to docker build when version tag is rolled. 2020-02-14 09:57:02 +01:00
Martyn 0af0f25be4 Sorta works
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 23:52:48 +01:00
Martyn 96b4b8f8e3 maybe drone likes this?
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 23:51:38 +01:00
Martyn 42f846a6bc Some debug
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 23:49:48 +01:00
Martyn 3aa8d59ead How likely is this to work?
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 23:47:33 +01:00
Martyn e4b1cdd869 Fix Dockerfile and move deployment stuff
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 16:48:32 +01:00
Martyn 3f358aa1fa Refactored to a package
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 16:39:57 +01:00
Martyn Ranyard 23a5cdc98c move irc to a package 2020-02-13 16:18:44 +01:00
Martyn 900fd69a59 Add build badge
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 14:55:27 +01:00
Martyn 6da13d2fae Fix the image name
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-02-13 14:54:10 +01:00
27 changed files with 2206 additions and 570 deletions

View File

@ -1,24 +0,0 @@
kind: pipeline
type: docker
name: linux-amd64
platform:
arch: amd64
os: linux
steps:
- name: publish
image: plugins/docker:18
settings:
auto_tag: true
auto_tag_suffix: linux-amd64
dockerfile: Dockerfile
repo: imartyn/karaokardbot
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- push
- tag

1
.gitignore vendored
View File

@ -12,3 +12,4 @@
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
karaokards

View File

@ -1,12 +0,0 @@
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"]

14
Makefile Executable file
View File

@ -0,0 +1,14 @@
BUILD=`date +%FT%T%z`
LDFLAGS=-ldflags "-X main.buildDate=${BUILD}"
.PHONY: build deps static
build:
go build ${LDFLAGS}
deps:
go get
static:
CGO_ENABLED=0 GOOS=linux go build ${LDFLAGS} -a -installsuffix cgo -o karaokards .

View File

@ -1,3 +1,9 @@
# karaokards
Silly little twitch bot that gives prompts for songs to sing
Silly little twitch bot that gives prompts for songs to sing.
Almost a hackday style project. Apologies for the mess.
Auto-built from my Drone CI and pushed to dockerhub.
[![Build Status](https://ci.martyn.berlin/api/badges/martyn/karaokards/status.svg)](https://ci.martyn.berlin/martyn/karaokards)

63
build/ci/drone.yml Normal file
View File

@ -0,0 +1,63 @@
kind: pipeline
type: docker
name: linux-amd64-taggedver
platform:
arch: amd64
os: linux
steps:
- name: build
image: golang
commands:
- pwd
- mkdir -p /go/src/git.martyn.berlin/martyn
- ln -s /drone/src /go/src/git.martyn.berlin/martyn/karaokards
- cd /go/src/git.martyn.berlin/martyn/karaokards
- go get
- go build
- name: publish
image: plugins/docker:18
settings:
auto_tag: true
auto_tag_suffix: linux-amd64
dockerfile: build/package/Dockerfile
repo: imartyn/karaokardbot
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- push
- tag
trigger:
ref:
- refs/tags/v*
---
kind: pipeline
type: docker
name: linux-amd64-devel-master
platform:
arch: amd64
os: linux
steps:
- name: build
image: golang
commands:
- pwd
- mkdir -p /go/src/git.martyn.berlin/martyn
- ln -s /drone/src /go/src/git.martyn.berlin/martyn/karaokards
- cd /go/src/git.martyn.berlin/martyn/karaokards
- go get
- go build
trigger:
ref:
- refs/heads/devel
- refs/heads/master

14
build/package/Dockerfile Executable file
View File

@ -0,0 +1,14 @@
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/git.martyn.berlin/martyn/karaokards/
COPY internal/ /go/src/git.martyn.berlin/martyn/karaokards/internal/
COPY Makefile /go/src/git.martyn.berlin/martyn/karaokards/
RUN cd /go/src/git.martyn.berlin/martyn/karaokards/; make deps ; make static
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /go/src/git.martyn.berlin/martyn/karaokards /app/
COPY strings.json /app/strings.json
COPY web/ /app/web/
WORKDIR /app
CMD ["/app/karaokards"]

View File

@ -1,12 +0,0 @@
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

4
configs/config.json Executable file
View File

@ -0,0 +1,4 @@
{
"channels": ["karaokards"],
"externalUrl": "karaokards.ing.martyn.berlin"
}

View File

@ -0,0 +1,12 @@
apiVersion: v1
data:
config.json: |
{
"channels": ["iMartynOnTwitch"],
"externalUrl": "karaokards.ing.martyn.berlin"
}
strings.json: "{\r\n \"strings\": [\r\n\t \"They're from the North\",\r\n
\ \"Refers to food\"\r\n ]\r\n}\n"
kind: ConfigMap
metadata:
name: kardconfig

View File

@ -1,17 +1,9 @@
apiVersion: extensions/v1beta1
apiVersion: apps/v1
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
@ -31,9 +23,12 @@ spec:
run: kardbot
spec:
containers:
- image: imartyn/karokardbot:0.0.1
- image: imartyn/karaokardbot:devel
imagePullPolicy: IfNotPresent
name: kardbot
ports:
- name: web
containerPort: 5353
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
@ -43,6 +38,11 @@ spec:
- mountPath: /app/strings.json
name: extracards
subPath: strings.json
- mountPath: /app/config.json
name: config
subPath: config.json
- mountPath: /data
name: data
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
@ -58,24 +58,15 @@ spec:
items:
- key: strings.json
path: strings.json
name: extracards
name: kardconfig
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
- configMap:
defaultMode: 420
items:
- key: config.json
path: config.json
name: kardconfig
name: config
- name: data
persistentVolumeClaim:
claimName: kkard-data

View File

@ -0,0 +1,21 @@
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
annotations:
certmanager.k8s.io/cluster-issuer: letsencrypt
name: karaokards
spec:
rules:
- host: karaokards-dev.ing.martyn.berlin
http:
paths:
- backend:
serviceName: karaokards
servicePort: 80
path: /
tls:
- hosts:
- karaokards-dev.ing.martyn.berlin
secretName: karaokards-dev-cert
status:
loadBalancer: {}

View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: kkard-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
run: kardbot
name: karaokards
spec:
externalTrafficPolicy: Cluster
ports:
- port: 80
protocol: TCP
targetPort: 5353
selector:
run: kardbot
sessionAffinity: None
type: LoadBalancer

181
internal/builtins/kards.go Normal file
View File

@ -0,0 +1,181 @@
package builtins
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, Im or Ive",
"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",
"Im 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",
"Ive seen them in real life",
"A beautiful love story",
"OK, this is just ridiculous",
"Artist begins with B",
"Artist begins with D",
"Theyre dead :(",
"Artist begins with L",
"Amazing hair",
"Artist begins with J"}

492
internal/irc/irc.go Normal file
View File

@ -0,0 +1,492 @@
package irc
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net"
"net/textproto"
"regexp"
"strings"
"time"
rgb "github.com/foresthoffman/rgblog"
uuid "github.com/google/uuid"
scribble "github.com/nanobox-io/golang-scribble"
)
const UTCFormat = "Jan 2 15:04:05 UTC"
// Regex for parsing connection messages
//
// First matched group is our real username - twitch doesn't complain at using NICK command but doesn't honor it.
var ConnectRegex *regexp.Regexp = regexp.MustCompile(`^:tmi.twitch.tv 001 ([^ ]+) .*`)
// Regex for parsing PRIVMSG strings.
//
// First matched group is the user's name, second is the channel? and the third matched group is the content of the
// user's message.
var MsgRegex *regexp.Regexp = regexp.MustCompile(`^:(\w+)!\w+@\w+\.tmi\.twitch\.tv (PRIVMSG) #(\w+)(?: :(.*))?$`)
var DirectMsgRegex *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 ConfigStruct struct {
InitialChannels []string `json:"channels"`
IrcOAuthPath string `json:"ircoauthpath,omitempty"`
StringPath string `json:"authpath,omitempty"`
DataPath string `json:"datapath,omitempty"`
ExternalUrl string `json:"externalurl,omitempty"`
AppOAuthPath string `json:"appoauthpath,omitempty"`
}
type KardBot struct {
Channel string
conn net.Conn
IrcCredentials *OAuthCred
AppCredentials *OAuthCred
MsgRate time.Duration
Name string
Port string
IrcPrivatePath string
AppPrivatePath string
Server string
startTime time.Time
Prompts []string
Database scribble.Driver
ChannelData map[string]ChannelData
Config ConfigStruct
}
type ChannelData struct {
Name string `json:"name"`
AdminKey string `json:"value,omitempty"`
Command string `json:"customcommand,omitempty"`
ExtraStrings string `json:"extrastrings,omitempty"`
JoinTime time.Time `json:"jointime"`
ControlChannel bool
HasLeft bool `json:"hasleft"`
}
// 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)
}
// Look at the channels I'm actually in
func (bb *KardBot) ActiveChannels() int {
count := 0
for _, channel := range bb.ChannelData {
if !channel.HasLeft {
count = count + 1
}
}
return count
}
// 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 {
matches := ConnectRegex.FindStringSubmatch(line)
if nil != matches {
realUserName := matches[1]
if bb.ChannelData[realUserName].Name == "" {
record := ChannelData{Name: realUserName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
bb.Database.Write("channelData", realUserName, record)
bb.ChannelData[realUserName] = record
}
bb.JoinChannel(realUserName)
}
matches = DirectMsgRegex.FindStringSubmatch(line)
if nil != matches {
userName := matches[1]
// msgType := matches[2]
// channel := matches[3]
msg := matches[4]
rgb.GPrintf("[%s] Direct message %s: %s\n", TimeStamp(), userName, msg)
}
// handle a PRIVMSG message
matches = MsgRegex.FindStringSubmatch(line)
if nil != matches {
userName := matches[1]
msgType := matches[2]
channel := matches[3]
switch msgType {
case "PRIVMSG":
msg := matches[4]
rgb.GPrintf("[%s] %s: %s\n", TimeStamp(), userName, msg)
rgb.GPrintf("[%s] raw line: %s\n", TimeStamp(), line)
// parse commands from user message
cmdMatches := CmdRegex.FindStringSubmatch(msg)
if nil != cmdMatches {
cmd := cmdMatches[1]
rgb.YPrintf("[%s] Checking cmd %s against %s\n", TimeStamp(), cmd, bb.ChannelData[channel].Command)
switch cmd {
case bb.ChannelData[channel].Command:
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
case "join":
if bb.ChannelData[channel].ControlChannel {
rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
if bb.ChannelData[userName].Name == "" {
record := ChannelData{Name: userName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
bb.Database.Write("channelData", userName, record)
bb.ChannelData[userName] = record
}
bb.JoinChannel(userName)
}
}
// channel-owner specific commands
if userName == channel {
switch cmd {
case "tbdown":
rgb.CPrintf(
"[%s] Shutdown command received. Shutting down now...\n",
TimeStamp(),
)
bb.Disconnect()
return nil
case "kcardadmin":
magicCode := bb.ReadOrCreateChannelKey(channel)
rgb.CPrintf(
"[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n",
TimeStamp(),
magicCode, userName, magicCode,
)
err := bb.Msg("Welcome to Karaokards, your admin panel is https://karaokards.ing.martyn.berlin/admin/"+userName+"/"+magicCode, userName)
if err != nil {
rgb.RPrintf("[%s] ERROR %s\n",err)
}
bb.Say("Ack.")
default:
// do nothing
}
}
}
default:
// do nothing
rgb.YPrintf("[%s] unknown IRC message : %s\n", TimeStamp(), line)
}
}
}
time.Sleep(bb.MsgRate)
}
}
// Login to the IRC server
func (bb *KardBot) Login() {
rgb.YPrintf("[%s] Logging into #%s...\n", TimeStamp(), bb.Channel)
bb.conn.Write([]byte("PASS " + bb.IrcCredentials.Password + "\r\n"))
bb.conn.Write([]byte("NICK " + bb.Name + "\r\n"))
}
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)
}
}
// Makes the bot join its pre-specified channel.
func (bb *KardBot) JoinChannel(channels ...string) {
if len(channels) == 0 {
channels = append(channels, bb.Channel)
}
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)
}
}
// Reads from the private credentials file and stores the data in the bot's appropriate Credentials field.
func (bb *KardBot) ReadCredentials(credType string) error {
var err error
var credFile []byte
// reads from the file
if credType == "IRC" {
credFile, err = ioutil.ReadFile(bb.IrcPrivatePath)
} else {
credFile, err = ioutil.ReadFile(bb.AppPrivatePath)
}
if nil != err {
return err
}
// parses the file contents
var creds OAuthCred
dec := json.NewDecoder(strings.NewReader(string(credFile)))
if err = dec.Decode(&creds); nil != err && io.EOF != err {
return err
}
if credType == "IRC" {
bb.IrcCredentials = &creds
} else {
bb.AppCredentials = &creds
}
return nil
}
func (bb *KardBot) Msg(msg string, users ...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")
}
if len(users) == 0 {
return errors.New("BasicBot.Say: users was empty.")
}
rgb.YPrintf("[%s] sending %s to users %v as @%s!\n", TimeStamp(), msg, users, bb.Name)
for _, channel := range users {
_, 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
}
}
return nil
}
// Makes the bot send a message to the chat channel.
func (bb *KardBot) Say(msg string, channels ...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")
}
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
}
}
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("IRC")
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
return
}
err = bb.ReadCredentials("App")
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
return
}
err = bb.readChannelData()
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
return
}
for {
bb.Connect()
bb.Login()
if len(bb.ChannelData) > 0 {
for channelName,channelData := range bb.ChannelData {
if !channelData.HasLeft {
bb.JoinChannel(channelName)
}
}
} else {
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 (bb *KardBot) readChannelData() error {
records, err := bb.Database.ReadAll("channelData")
if err != nil {
// no db? initialise one?
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
rgb.YPrintf("[%s] No channel table for #%s exists, creating...\n", TimeStamp(), bb.Channel)
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
return err
}
bb.ChannelData = make(map[string]ChannelData)
bb.ChannelData[bb.Channel] = record
} else {
bb.ChannelData = make(map[string]ChannelData)
}
for _, data := range records {
record := ChannelData{}
err := json.Unmarshal([]byte(data), &record)
if err != nil {
return err
}
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")
}
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(bb.ChannelData))
return nil
}
func (bb *KardBot) ReadOrCreateChannelKey(channel string) string {
magicCode := ""
var err error
var record ChannelData
if record, ok := bb.ChannelData[channel]; !ok {
rgb.YPrintf("[%s] No channel data for #%s exists, creating\n", TimeStamp(), channel)
err = bb.Database.Read("channelData", channel, &record)
if err == nil {
bb.ChannelData[channel] = record
}
}
record = bb.ChannelData[channel]
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.HasLeft = true
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)
}
bb.ChannelData[record.Name] = record
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
}
func TimeStamp() string {
return TimeStampFmt(UTCFormat)
}
func TimeStampFmt(format string) string {
return time.Now().Format(format)
}

374
internal/webserver/webserver.go Executable file
View File

@ -0,0 +1,374 @@
package webserver
import (
"math/rand"
irc "git.martyn.berlin/martyn/karaokards/internal/irc"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"encoding/json"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"time"
)
//var store = sessions.NewCookieStore(os.Getenv("SESSION_KEY"))
type twitchauthresponse struct {
Access_token string `json: "access_token"`
Expires_in int `json: "expires_in"`
Refresh_token string `json: "refresh_token"`
Scope []string `json: "scope"`
Token_type string `json: "token_type"`
}
type twitchUser struct {
Id string `json: "id"`
Login string `json: "login"`
Display_name string `json: "display_name"`
Type string `json: "type"`
Broadcaster_type string `json: "affiliate"`
Description string `json: "description"`
Profile_image_url string `json: "profile_image_url"`
Offline_image_url string `json: "offline_image_url"`
View_count int `json: "view_count"`
}
type twitchUsersBigResponse struct {
Data []twitchUser `json:"data"`
}
var ircBot *irc.KardBot
func HealthHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "I'm okay jack!")
}
func NotFoundHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("X-Template-File", "html"+request.URL.Path)
response.WriteHeader(404)
tmpl := template.Must(template.ParseFiles("web/404.html"))
tmpl.Execute(response, nil)
}
func CSSHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("Content-type", "text/css")
tmpl := template.Must(template.ParseFiles("web/cover.css"))
tmpl.Execute(response, nil)
}
func RootHandler(response http.ResponseWriter, request *http.Request) {
request.URL.Path = "/index.html"
TemplateHandler(response, request)
}
func TemplateHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("X-Template-File", "web"+request.URL.Path)
type TemplateData struct {
Prompt string
AvailCount int
ChannelCount int
MessageCount int
ClientID string
BaseURI string
}
// tmpl, err := template.New("html"+request.URL.Path).Funcs(template.FuncMap{
// "ToUpper": strings.ToUpper,
// "ToLower": strings.ToLower,
// }).ParseFiles("html"+request.URL.Path)
_ = strings.ToLower("Hello")
if strings.Index(request.URL.Path, "/") < 0 {
http.Error(response, "No slashes wat - "+request.URL.Path, http.StatusInternalServerError)
return
}
basenameSlice := strings.Split(request.URL.Path, "/")
basename := basenameSlice[len(basenameSlice)-1]
//fmt.Fprintf(response, "%q", basenameSlice)
tmpl, err := template.New(basename).Funcs(template.FuncMap{
"ToUpper": strings.ToUpper,
"ToLower": strings.ToLower,
}).ParseFiles("web" + request.URL.Path)
if err != nil {
http.Error(response, err.Error(), http.StatusInternalServerError)
return
// NotFoundHandler(response, request)
// return
}
var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + ircBot.Config.ExternalUrl}
err = tmpl.Execute(response, td)
if err != nil {
http.Error(response, err.Error(), http.StatusInternalServerError)
return
}
}
func LeaveHandler(response http.ResponseWriter, request *http.Request) {
request.URL.Path = "/bye.html"
TemplateHandler(response, request)
}
func AdminHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
UnauthorizedHandler(response, request)
return
}
type TemplateData struct {
Channel string
Command string
ExtraStrings string
SinceTime time.Time
SinceTimeUTC string
Leaving bool
HasLeft bool
}
channelData := ircBot.ChannelData[vars["channel"]]
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft}
if request.Method == "POST" {
request.ParseForm()
if strings.Join(request.PostForm["leave"], ",") == "Leave twitch channel" {
td.Leaving = true
} else if strings.Join(request.PostForm["reallyleave"], ",") == "Really leave twitch channel" {
record := ircBot.ChannelData[vars["channel"]]
record.HasLeft = true
ircBot.ChannelData[vars["channel"]] = record
ircBot.LeaveChannel(vars["channel"])
ircBot.Database.Write("channelData", vars["channel"], record)
LeaveHandler(response, request)
return
}
if strings.Join(request.PostForm["join"], ",") == "Come on in" {
record := ircBot.ChannelData[vars["channel"]]
td.HasLeft = false
record.Name = vars["channel"]
record.JoinTime = time.Now()
record.HasLeft = false
if record.Command == "" {
record.Command = "card"
}
ircBot.Database.Write("channelData", vars["channel"], record)
ircBot.ChannelData[vars["channel"]] = record
td = TemplateData{record.Name, record.Command, record.ExtraStrings, record.JoinTime, record.JoinTime.Format(irc.UTCFormat), false, record.HasLeft}
ircBot.JoinChannel(record.Name)
}
sourceData := ircBot.ChannelData[vars["channel"]]
if strings.Join(request.PostForm["Command"], ",") != "" {
sourceData.Command = strings.Join(request.PostForm["Command"], ",")
td.Command = sourceData.Command
ircBot.ChannelData[vars["channel"]] = sourceData
}
if strings.Join(request.PostForm["ExtraStrings"], ",") != sourceData.ExtraStrings {
sourceData.ExtraStrings = strings.Join(request.PostForm["ExtraStrings"], ",")
td.ExtraStrings = sourceData.ExtraStrings
ircBot.ChannelData[vars["channel"]] = sourceData
}
ircBot.Database.Write("channelData", vars["channel"], sourceData)
}
tmpl := template.Must(template.ParseFiles("web/admin.html"))
tmpl.Execute(response, td)
}
func UnauthorizedHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("X-Template-File", "html"+request.URL.Path)
response.WriteHeader(401)
tmpl := template.Must(template.ParseFiles("web/401.html"))
tmpl.Execute(response, nil)
}
func twitchHTTPClient(call string, bearer string) (string, error) {
url := "https://api.twitch.tv/helix/" + call
var bearerHeader = "Bearer " + bearer
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("Client-ID", ircBot.AppCredentials.ClientID)
req.Header.Add("Authorization", bearerHeader)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
return string([]byte(body)), nil
}
func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
if vars["code"] != "" {
response.Header().Add("Content-type", "text/plain")
form := url.Values{
"client_id": {ircBot.AppCredentials.ClientID},
"client_secret": {ircBot.AppCredentials.Password},
"code": {vars["code"]},
"grant_type": {"authorization_code"},
"redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}}
req, err := http.NewRequest("POST", "https://id.twitch.tv/oauth2/token", strings.NewReader(form.Encode()))
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
req.Header.Add("Client-ID", ircBot.AppCredentials.ClientID)
req.Header.Add("Authorization", "Bearer "+ircBot.AppCredentials.Password)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
var oauthResponse twitchauthresponse
err = json.Unmarshal(body, &oauthResponse)
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
usersResponse, err := twitchHTTPClient("users", oauthResponse.Access_token)
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
var usersObject twitchUsersBigResponse
err = json.Unmarshal([]byte(usersResponse), &usersObject)
if err != nil {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
return
}
if len(usersObject.Data) != 1 {
response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: Twitch returned not 1 user for the request!\n---\n")
fmt.Fprint(response, usersObject.Data)
fmt.Fprint(response, "\n---\n")
fmt.Fprint(response, body)
fmt.Fprint(response, "\n---\n")
fmt.Fprint(response, usersResponse)
fmt.Fprint(response, "\n---\n")
fmt.Fprint(response, usersObject)
fmt.Fprint(response, "\n---\n")
fmt.Fprint(response, "curl -H 'Authorization: Bearer "+oauthResponse.Access_token+
"' -H 'Client-ID: "+ircBot.AppCredentials.ClientID+
" -X GET https://api.twitch.tv/users")
return
}
user := usersObject.Data[0]
magicCode := ircBot.ReadOrCreateChannelKey(user.Login)
url := "https://" + ircBot.Config.ExternalUrl + "/admin/" + user.Login + "/" + magicCode
http.Redirect(response, request, url, http.StatusFound)
} else {
fmt.Fprintf(response, "I'm not okay jack! %v \n", vars)
for key, val := range vars {
fmt.Fprint(response, "%s = %s\n", key, val)
}
}
}
func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("Content-type", "text/plain")
vars := mux.Vars(request)
// fmt.Fprintf(response, "I'm okay jack! %v \n", vars)
// for key, val := range(vars) {
// fmt.Fprint(response, "%s = %s\n", key, val)
// }
if vars["code"] != "" {
// https://id.twitch.tv/oauth2/token
// ?client_id=<your client ID>
// &client_secret=<your client secret>
// &code=<authorization code received above>
// &grant_type=authorization_code
// &redirect_uri=<your registered redirect URI>
// ircBot.AppCredentials.ClientID
// ircBot.AppCredentials.Password
// vars["oauthtoken"]
// authorization_code
// "https://"+ircBot.Config.ExternalUrl+/twitchadmin
fmt.Println("Asking twitch for more...")
resp, err := http.PostForm(
"https://id.twitch.tv/oauth2/token",
url.Values{
"client_id": {ircBot.AppCredentials.ClientID},
"client_secret": {ircBot.AppCredentials.Password},
"code": {vars["code"]},
"grant_type": {"authorization_code"},
"redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}})
if err != nil {
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error())
}
response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, string(body))
} else {
UnauthorizedHandler(response, request)
}
}
func HandleHTTP(passedIrcBot *irc.KardBot) {
ircBot = passedIrcBot
r := mux.NewRouter()
loggedRouter := handlers.LoggingHandler(os.Stdout, r)
r.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
r.HandleFunc("/", RootHandler)
r.HandleFunc("/healthz", HealthHandler)
r.HandleFunc("/web/{.*}", TemplateHandler)
r.PathPrefix("/static/").Handler(http.FileServer(http.Dir("./web/")))
r.HandleFunc("/cover.css", CSSHandler)
r.HandleFunc("/admin/{channel}/{key}", AdminHandler)
//r.HandleFunc("/twitchadmin", TwitchAdminHandler)
//r.HandleFunc("/twitchtobackend", TwitchBackendHandler)
r.Path("/twitchtobackend").Queries("access_token", "{access_token}", "scope", "{scope}", "token_type", "{token_type}").HandlerFunc(TwitchBackendHandler)
r.Path("/twitchadmin").Queries("code", "{code}", "scope", "{scope}").HandlerFunc(TwitchAdminHandler)
http.Handle("/", r)
srv := &http.Server{
Handler: loggedRouter,
Addr: "0.0.0.0:5353",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
fmt.Println("Listening on 0.0.0.0:5353")
srv.ListenAndServe()
}

Binary file not shown.

727
main.go
View File

@ -1,491 +1,236 @@
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, Im or Ive",
"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",
"Im 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",
"Ive seen them in real life",
"A beautiful love story",
"OK, this is just ridiculous",
"Artist begins with B",
"Artist begins with D",
"Theyre 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()
}
package main
import (
"encoding/json"
"io/ioutil"
"math/rand"
"os"
"path/filepath"
"time"
builtins "git.martyn.berlin/martyn/karaokards/internal/builtins"
irc "git.martyn.berlin/martyn/karaokards/internal/irc"
webserver "git.martyn.berlin/martyn/karaokards/internal/webserver"
rgb "github.com/foresthoffman/rgblog"
scribble "github.com/nanobox-io/golang-scribble"
)
type customStringsStruct struct {
Strings []string `json:"strings,omitempty"`
}
var selectablePrompts []string
var customStrings customStringsStruct
var config irc.ConfigStruct
func readConfig() {
var data []byte
var err error
configFile := ""
if os.Getenv("KARAOKARDS_CONFIGFILE") != "" {
if _, err := os.Stat(os.Getenv("KARAOKARDS_CONFIGFILE")); os.IsNotExist(err) {
rgb.RPrintf("[%s] Error, KARAOKARDS_CONFIGFILE env var set and '%s' doesn't exist!\n", irc.TimeStamp(), os.Getenv("KARAOKARDS_CONFIGFILE"))
os.Exit(1)
}
configFile = os.Getenv("KARAOKARDS_CONFIGFILE")
} else {
ex, err := os.Executable()
if err != nil {
rgb.YPrintf("[%s] Warning, KARAOKARDS_CONFIGFILE env var unset and cannot find executable!\n", irc.TimeStamp())
}
exPath := filepath.Dir(ex)
if _, err := os.Stat(exPath + "/config.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Warning, KARAOKARDS_CONFIGFILE env var unset and `config.json` not alongside executable!\n", irc.TimeStamp())
if _, err := os.Stat("/etc/karaokards/config.json"); os.IsNotExist(err) {
rgb.RPrintf("[%s] Error, KARAOKARDS_CONFIGFILE env var unset and neither '%s' nor '%s' exist!\n", irc.TimeStamp(), exPath+"/config.json", "/etc/karaokards/config.json")
os.Exit(1)
} else {
configFile = "/etc/karaokards/config.json"
}
} else {
configFile = exPath + "/config.json"
}
}
data, err = ioutil.ReadFile(configFile)
if err != nil {
rgb.RPrintf("[%s] Could not read `%s`. File reading error: %s\n", irc.TimeStamp(), configFile, err)
os.Exit(1)
}
err = json.Unmarshal(data, &config)
if err != nil {
rgb.RPrintf("[%s] Could not unmarshal `%s`. Unmarshal error: %s\n", irc.TimeStamp(), configFile, err)
os.Exit(1)
}
rgb.YPrintf("[%s] Read config file from `%s`\n", irc.TimeStamp(), configFile)
rgb.YPrintf("[%s] config %v\n", irc.TimeStamp(), config)
return
}
//openDatabase "database" in this sense being a scribble db
func openDatabase() *scribble.Driver {
dataPath := ""
if config.DataPath == "" {
if os.Getenv("KARAOKARDS_DATA_FOLDER") != "" {
if _, err := os.Stat(os.Getenv("KARAOKARDS_DATA_FOLDER")); os.IsNotExist(err) {
rgb.RPrintf("[%s] Error, KARAOKARDS_DATA_FOLDER env var set and '%s' doesn't exist!\n", irc.TimeStamp(), os.Getenv("KARAOKARDS_DATA_FOLDER"))
os.Exit(1)
}
dataPath = os.Getenv("KARAOKARDS_DATA_FOLDER")
} else {
ex, err := os.Executable()
if err != nil {
rgb.RPrintf("[%s] Error, KARAOKARDS_DATA_FOLDER env var unset and cannot find executable!\n", irc.TimeStamp())
os.Exit(1)
}
exPath := filepath.Dir(ex)
if _, err := os.Stat(exPath + "/data"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Warning %s doesn't exist, trying to create it.\n", irc.TimeStamp(), exPath+"/data")
err = os.Mkdir(exPath+"/data", 0770)
if err != nil {
rgb.RPrintf("[%s] Error cannot create %s: %s!\n", irc.TimeStamp(), exPath+"/data", err)
os.Exit(1)
}
}
dataPath = exPath + "/data"
}
} else {
if _, err := os.Stat(config.DataPath); os.IsNotExist(err) {
rgb.RPrintf("[%s] Error, config-specified path '%s' doesn't exist!\n", irc.TimeStamp(), config.DataPath)
os.Exit(1)
}
dataPath = config.DataPath
}
db, err := scribble.New(dataPath, nil)
if err != nil {
rgb.RPrintf("[%s] Error opening database in '%s' : %s\n", irc.TimeStamp(), dataPath, err)
os.Exit(1)
}
return db
}
func readBonusStrings() []string {
var data []byte
var err error
if config.StringPath == "" {
ex, err := os.Executable()
if err != nil {
rgb.YPrintf("[%s] Could not read `strings.json`, will only have builtin prompts. File reading error: %s\n", irc.TimeStamp(), err)
return []string{}
}
exPath := filepath.Dir(ex)
data, err = ioutil.ReadFile(exPath + "/strings.json")
if err != nil {
rgb.YPrintf("[%s] Could not read `strings.json`, will only have builtin prompts. File reading error: %s\n", irc.TimeStamp(), err)
return []string{}
}
} else {
data, err = ioutil.ReadFile(config.StringPath)
if err != nil {
rgb.YPrintf("[%s] Could not read `strings.json`, will only have builtin prompts. File reading error: %s\n", irc.TimeStamp(), err)
return []string{}
}
}
err = json.Unmarshal(data, &customStrings)
if err != nil {
rgb.YPrintf("[%s] Could not unmarshal `strings.json`, will only have builtin prompts. Unmarshal error: %s\n", irc.TimeStamp(), err)
return []string{}
}
rgb.YPrintf("[%s] Read %d prompts from `strings.json`\n", irc.TimeStamp(), len(customStrings.Strings))
return customStrings.Strings
}
var buildDate string
func main() {
rgb.YPrintf("[%s] starting karaokard bot build %s\n", irc.TimeStamp(), buildDate)
readConfig()
rand.Seed(time.Now().UnixNano())
for _, val := range builtins.Karaokards {
selectablePrompts = append(selectablePrompts, val)
}
for _, val := range readBonusStrings() {
selectablePrompts = append(selectablePrompts, val)
}
persistentData := openDatabase()
var dbGlobalPrompts []string
if err := persistentData.Read("prompts", "global", &dbGlobalPrompts); err != nil {
persistentData.Write("prompts", "common", dbGlobalPrompts)
}
selectablePrompts := append(selectablePrompts, dbGlobalPrompts...)
rgb.YPrintf("[%s] %d prompts available.\n", irc.TimeStamp(), len(selectablePrompts))
ircOauthPath := ""
if config.IrcOAuthPath == "" {
if os.Getenv("TWITCH_OAUTH_JSON") != "" {
if _, err := os.Stat(os.Getenv("TWITCH_OAUTH_JSON")); os.IsNotExist(err) {
os.Exit(1)
}
ircOauthPath = os.Getenv("TWITCH_OAUTH_JSON")
} else {
if _, err := os.Stat(os.Getenv("HOME") + "/.twitch/ircoauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Warning %s doesn't exist, trying %s next!\n", irc.TimeStamp(), os.Getenv("HOME")+"/.twitch/ircoauth.json", "/etc/twitch/ircoauth.json")
if _, err := os.Stat("/etc/twitch/ircoauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error %s doesn't exist either, bailing!\n", irc.TimeStamp(), "/etc/twitch/ircoauth.json")
os.Exit(1)
}
ircOauthPath = "/etc/twitch/ircoauth.json"
} else {
ircOauthPath = os.Getenv("HOME") + "/.twitch/ircoauth.json"
}
}
} else {
if _, err := os.Stat(config.IrcOAuthPath); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.IrcOAuthPath)
os.Exit(1)
}
ircOauthPath = config.IrcOAuthPath
}
appOauthPath := ""
if config.AppOAuthPath == "" {
if os.Getenv("TWITCH_OAUTH_JSON") != "" {
if _, err := os.Stat(os.Getenv("TWITCH_OAUTH_JSON")); os.IsNotExist(err) {
os.Exit(1)
}
appOauthPath = os.Getenv("TWITCH_OAUTH_JSON")
} else {
if _, err := os.Stat(os.Getenv("HOME") + "/.twitch/appoauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Warning %s doesn't exist, trying %s next!\n", irc.TimeStamp(), os.Getenv("HOME")+"/.twitch/appoauth.json", "/etc/twitch/appoauth.json")
if _, err := os.Stat("/etc/twitch/appoauth.json"); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error %s doesn't exist either, bailing!\n", irc.TimeStamp(), "/etc/twitch/appoauth.json")
os.Exit(1)
}
appOauthPath = "/etc/twitch/appoauth.json"
} else {
appOauthPath = os.Getenv("HOME") + "/.twitch/appoauth.json"
}
}
} else {
if _, err := os.Stat(config.AppOAuthPath); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
os.Exit(1)
}
appOauthPath = config.AppOAuthPath
}
// Replace the channel name, bot name, and the path to the private directory with your respective
// values.
myBot := irc.KardBot{
Channel: "karaokards",
MsgRate: time.Duration(20/30) * time.Millisecond,
Name: "Karaokards",
Port: "6667",
IrcPrivatePath: ircOauthPath,
AppPrivatePath: appOauthPath,
Server: "irc.chat.twitch.tv",
Prompts: selectablePrompts,
Database: *persistentData,
Config: config,
}
go func() {
rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353")
webserver.HandleHTTP(&myBot)
}()
myBot.Start()
}

107
web/401.html Executable file
View File

@ -0,0 +1,107 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards">
<meta name="author" content="Martyn Ranyard">
<title>The great unknown!</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
</style>
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">k8s-zoo</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Scary user alert!</h1>
<img src="https://http.cat/401" alt="Cat sat outside a door that has a sign depictinging no cats allowed" />
<p>It seems you've gone somewhere you shouldn't! 401 NOT AUTHORIZED!</p>
<p/>
<p>I'm not quite sure how you got here to be honest, if it was via a link on the site, let me know via twitch DM, if it was from someone else, let them know.</p>
<p>Shameless self-promotion : Follow me on twitch - <a href="https://www.twitch.tv/iMartynOnTwitch">iMartynOnTwitch</a>, oddly enough, I do a lot of twitchsings!</p>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
<script>
function directToResults() {
var url = document.createElement('a');
url.setAttribute("href", window.location.href);
if ((url.port != 80) && (url.port != 443)) {
customPort = ":"+url.port
} else {
customPort = ""
}
var destination = url.protocol + "//" + url.hostname + customPort + "/" + document.getElementById("mode").value + "/" + document.getElementById("spotifyid").value
window.location.href = destination
}
function toggleUnfound() {
var unmatched = document.getElementsByClassName('match-nomatch'), i;
if (document.getElementById("showhidebutton").getAttribute("tracksHidden") != "true") {
document.getElementById("showhidebutton").setAttribute("tracksHidden","true")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'none';
}
} else {
document.getElementById("showhidebutton").setAttribute("tracksHidden","false")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'list-item';
}
}
}
</script>
</body>
</html>
</body>
</html>

106
web/404.html Normal file
View File

@ -0,0 +1,106 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards">
<meta name="author" content="Martyn Ranyard">
<title>The great unknown!</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
</style>
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">k8s-zoo</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Ooops!</h1>
<p>It seems you've gone somewhere you shouldn't! 404 NOT FOUND!</p>
<p/>
<p>I'm not quite sure how you got here to be honest, if it was via a link on the site, let me know via twitch DM, if it was from someone else, let them know.</p>
<p>Shameless self-promotion : Follow me on twitch - <a href="https://www.twitch.tv/iMartynOnTwitch">iMartynOnTwitch</a>, oddly enough, I do a lot of twitchsings!</p>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
<script>
function directToResults() {
var url = document.createElement('a');
url.setAttribute("href", window.location.href);
if ((url.port != 80) && (url.port != 443)) {
customPort = ":"+url.port
} else {
customPort = ""
}
var destination = url.protocol + "//" + url.hostname + customPort + "/" + document.getElementById("mode").value + "/" + document.getElementById("spotifyid").value
window.location.href = destination
}
function toggleUnfound() {
var unmatched = document.getElementsByClassName('match-nomatch'), i;
if (document.getElementById("showhidebutton").getAttribute("tracksHidden") != "true") {
document.getElementById("showhidebutton").setAttribute("tracksHidden","true")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'none';
}
} else {
document.getElementById("showhidebutton").setAttribute("tracksHidden","false")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'list-item';
}
}
}
</script>
</body>
</html>
</body>
</html>

143
web/admin.html Executable file
View File

@ -0,0 +1,143 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards bot for twitch chat">
<meta name="author" content="Martyn Ranyard">
<title>Karaokards</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
.displaySetting{
display: inline
}
.hiddenDisplaySetting{
display: none;
}
.hiddenSave {
display: none;
}
.editSetting{
display: none;
}
.visibleEditSetting{
display: inline;
}
.visibleSave {
display: inline;
}
</style>
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Karaokards</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<script>
function editMode() {
var alldisps = document.getElementsByClassName("displaySetting");
for (item of alldisps) {
item.classList.add("hiddenDisplaySetting")
}
for (item of alldisps) {
item.classList.remove("displaySetting")
}
var alldisps = document.getElementsByClassName("editSetting");
for (item of alldisps) {
item.classList.add("visibleEditSetting")
}
for (item of alldisps) {
item.classList.remove("editSetting")
}
document.getElementById("saveButton").classList.remove("hiddenSave")
document.getElementById("saveButton").classList.add("visibleSave")
document.getElementById("leaveButton").classList.remove("hiddenSave")
document.getElementById("leaveButton").classList.add("visibleSave")
document.getElementById("yuhateme").classList.remove("hiddenSave")
document.getElementById("yuhateme").classList.add("visibleSave")
}
</script>
<h1 class="cover-heading">Karaokards admin panel for {{.Channel}}!!!</h1>
<form method="POST">
{{ if .HasLeft }}
<h2>Not in your channel at the moment!</h2>
<p>The bot is not currently in your channel, chances are you've not ever asked it to join, you asked it to leave, or something went horribly wrong.</p>
<p>You can invite the bot to your channel by clicking here : <input id="joinButton" type="submit" name="join" value="Come on in"></p>
{{ else }}
{{ if .Leaving }}
<h2>Do you really want this bot to leave your channel?</h2>
<p><input id="leaveButton" type="submit" name="reallyleave" value="Really leave twitch channel"></p>
{{ else }}
<h2>Note you can give your moderators the url you are on right now to control this bot. They don't have to be logged into twitch to do so.</h2>
<table>
<thead><tr><td>Channel Data :</td><td><input type="button" value="Edit" onclick="javascript:editMode();"></td></tr></thead>
<tbody>
<tr><td>Member of channel since {{.SinceTimeUTC}}</td></tr>
<tr><td>Command for prompt:</td><td class="displaySetting"></tdclass>{{.Command}}</td><td class="editSetting"><input type="text" name="Command" value="{{.Command}}"></td></tr>
<tr><td>Extra prompts (one per line):</td><td class="displaySetting">{{.ExtraStrings}}</td><td class="editSetting"><textarea name="ExtraStrings" >{{.ExtraStrings}}</textarea></td></tr>
<tr><td>&nbsp;</td><td><input id="saveButton" type="submit" class="hiddenSave" name="save" value="Save changes"></td></tr>
<tr id="yuhateme" class="hiddenSave"><td>Or... please don't go but...</td></tr>
<tr><td><input id="leaveButton" type="submit" class="hiddenSave" name="leave" value="Leave twitch channel"></td></tr>
</tbody>
</table>
{{ end }}
{{ end }}
</form>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
</body>
</html>
</body>
</html>

75
web/bye.html Executable file
View File

@ -0,0 +1,75 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards bot for twitch chat">
<meta name="author" content="Martyn Ranyard">
<title>Karaokards</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
</style>
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Karaokards</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Left channel!!!</h1>
<p><img src="/static/okaybye.gif" alt="animation of dissappointed sister from Frozen saying Okay, bye..."/></p>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
</body>
</html>
</body>
</html>

106
web/cover.css Normal file
View File

@ -0,0 +1,106 @@
/*
* Globals
*/
/* Links */
a,
a:focus,
a:hover {
color: #fff;
}
/* Custom default button */
.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from body */
background-color: #fff;
border: .05rem solid #fff;
}
/*
* Base structure
*/
html,
body {
height: 100%;
background-color: #333;
}
body {
display: -ms-flexbox;
display: flex;
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}
.cover-container {
max-width: 142em;
}
/*
* Header
*/
.masthead {
margin-bottom: 2rem;
}
.masthead-brand {
margin-bottom: 0;
}
.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
color: rgba(255, 255, 255, .5);
background-color: transparent;
border-bottom: .25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}
@media (min-width: 48em) {
.masthead-brand {
float: left;
}
.nav-masthead {
float: right;
}
}
/*
* Cover
*/
.cover {
padding: 0 1.5rem;
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
/*
* Footer
*/
.mastfoot {
color: rgba(255, 255, 255, .5);
}

91
web/index.html Normal file
View File

@ -0,0 +1,91 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards bot for twitch chat">
<meta name="author" content="Martyn Ranyard">
<title>Karaokards</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
</style>
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Karaokards</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
<a class="nav-link active" href="https://id.twitch.tv/oauth2/authorize?client_id={{.ClientID}}&redirect_uri={{.BaseURI}}/twitchadmin&response_type=code&scope=user:read:broadcast">Admin - log in with twitch</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Karaokards!!!</h1>
<p>Random prompt for you : {{.Prompt}}</p>
<p>There are a total of {{.AvailCount}} prompts available. This bot is hanging out in {{.ChannelCount}} channels and has served {{.MessageCount}} prompts via twitch chat!</p>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
<script>
function releaseAnimal() {
var url = document.createElement('a');
url.setAttribute("href", window.location.href);
if ((url.port != 80) && (url.port != 443)) {
customPort = ":"+url.port
} else {
customPort = ""
}
var destination = url.protocol + "//" + url.hostname + customPort + "/release"
window.location.href = destination
}
</script>
</body>
</html>
</body>
</html>

111
web/standby.html Executable file
View File

@ -0,0 +1,111 @@
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards">
<meta name="author" content="Martyn Ranyard">
<title>Please Stand By!</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/cover.css" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
li.match-nomatch{
background-color: #1e2122;
}
li.match-matchtrack{
background-color: #E9B000;
}
li.match-fullmatch{
background-color: #008F95;
}
li.match-matchtrackfuzzt{
background-color: darkgray;
}
li.match-fullmatchfuzzy{
background-color: darkgray;
}
a{
text-decoration-line: underline;
}
</style>
</head>
<body class="text-center" onload=directToResults()>
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Karaokards</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Please stand by, twitch gives us stuff that needs to be sent to the server!</h1>
<img src="https://media.giphy.com/media/ule4vhcY1xEKQ/source.gif" alt="Cats typing furiously" />
<p/>
<p>Just hold on, the javascript is doing it's stuff. Don't blame me, twitch really forces us to use javascript here.</p>
<p>Shameless self-promotion : Follow me on twitch - <a href="https://www.twitch.tv/iMartynOnTwitch">iMartynOnTwitch</a>, oddly enough, I do a lot of twitchsings!</p>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
<script>
// #access_token=hkq5diaiopu23tzyo5oik7jl7g2w0n&scope=user%3Aread%3Abroadcast&token_type=bearer
function directToResults() {
var url = document.createElement('a');
url.setAttribute("href", window.location.href);
if ((url.port != 80) && (url.port != 443)) {
customPort = ":"+url.port
} else {
customPort = ""
}
u = new URLSearchParams(document.location.hash.substr(1))
var destination = new URL(url.protocol + "//" + url.hostname + customPort + "/twitchtobackend?" + u.toString())
console.log(destination)
window.location.href = destination
}
function toggleUnfound() {
var unmatched = document.getElementsByClassName('match-nomatch'), i;
if (document.getElementById("showhidebutton").getAttribute("tracksHidden") != "true") {
document.getElementById("showhidebutton").setAttribute("tracksHidden","true")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'none';
}
} else {
document.getElementById("showhidebutton").setAttribute("tracksHidden","false")
for (i = 0; i < unmatched.length; i += 1) {
unmatched[i].style.display = 'list-item';
}
}
}
</script>
</body>
</html>
</body>
</html>

BIN
web/static/okaybye.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB