commit 0ac377da9052221f9ff663e97e0714accefbabbc Author: Martyn Ranyard Date: Tue Jul 14 20:56:20 2020 +0200 initial commit Signed-off-by: Martyn Ranyard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcdff6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ---> Go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +/twitchsingstools diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f27167 --- /dev/null +++ b/LICENSE @@ -0,0 +1,319 @@ +GNU GENERAL PUBLIC LICENSE + +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc. + +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 , USA + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your freedom to share +and change it. By contrast, the GNU General Public License is intended to +guarantee your freedom to share and change free software--to make sure the +software is free for all its users. This General Public License applies to +most of the Free Software Foundation's software and to any other program whose +authors commit to using it. (Some other Free Software Foundation software +is covered by the GNU Lesser General Public License instead.) You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for this service if you +wish), that you receive source code or can get it if you want it, that you +can change the software or use pieces of it in new free programs; and that +you know you can do these things. + +To protect your rights, we need to make restrictions that forbid anyone to +deny you these rights or to ask you to surrender the rights. These restrictions +translate to certain responsibilities for you if you distribute copies of +the software, or if you modify it. + +For example, if you distribute copies of such a program, whether gratis or +for a fee, you must give the recipients all the rights that you have. You +must make sure that they, too, receive or can get the source code. And you +must show them these terms so they know their rights. + +We protect your rights with two steps: (1) copyright the software, and (2) +offer you this license which gives you legal permission to copy, distribute +and/or modify the software. + +Also, for each author's protection and ours, we want to make certain that +everyone understands that there is no warranty for this free software. If +the software is modified by someone else and passed on, we want its recipients +to know that what they have is not the original, so that any problems introduced +by others will not reflect on the original authors' reputations. + +Finally, any free program is threatened constantly by software patents. We +wish to avoid the danger that redistributors of a free program will individually +obtain patent licenses, in effect making the program proprietary. To prevent +this, we have made it clear that any patent must be licensed for everyone's +free use or not licensed at all. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains a notice +placed by the copyright holder saying it may be distributed under the terms +of this General Public License. The "Program", below, refers to any such program +or work, and a "work based on the Program" means either the Program or any +derivative work under copyright law: that is to say, a work containing the +Program or a portion of it, either verbatim or with modifications and/or translated +into another language. (Hereinafter, translation is included without limitation +in the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not covered +by this License; they are outside its scope. The act of running the Program +is not restricted, and the output from the Program is covered only if its +contents constitute a work based on the Program (independent of having been +made by running the Program). Whether that is true depends on what the Program +does. + +1. You may copy and distribute verbatim copies of the Program's source code +as you receive it, in any medium, provided that you conspicuously and appropriately +publish on each copy an appropriate copyright notice and disclaimer of warranty; +keep intact all the notices that refer to this License and to the absence +of any warranty; and give any other recipients of the Program a copy of this +License along with the Program. + +You may charge a fee for the physical act of transferring a copy, and you +may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion of it, +thus forming a work based on the Program, and copy and distribute such modifications +or work under the terms of Section 1 above, provided that you also meet all +of these conditions: + +a) You must cause the modified files to carry prominent notices stating that +you changed the files and the date of any change. + +b) You must cause any work that you distribute or publish, that in whole or +in part contains or is derived from the Program or any part thereof, to be +licensed as a whole at no charge to all third parties under the terms of this +License. + +c) If the modified program normally reads commands interactively when run, +you must cause it, when started running for such interactive use in the most +ordinary way, to print or display an announcement including an appropriate +copyright notice and a notice that there is no warranty (or else, saying that +you provide a warranty) and that users may redistribute the program under +these conditions, and telling the user how to view a copy of this License. +(Exception: if the Program itself is interactive but does not normally print +such an announcement, your work based on the Program is not required to print +an announcement.) + +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Program, and can be reasonably +considered independent and separate works in themselves, then this License, +and its terms, do not apply to those sections when you distribute them as +separate works. But when you distribute the same sections as part of a whole +which is a work based on the Program, the distribution of the whole must be +on the terms of this License, whose permissions for other licensees extend +to the entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise +the right to control the distribution of derivative or collective works based +on the Program. + +In addition, mere aggregation of another work not based on the Program with +the Program (or with a work based on the Program) on a volume of a storage +or distribution medium does not bring the other work under the scope of this +License. + +3. You may copy and distribute the Program (or a work based on it, under Section +2) in object code or executable form under the terms of Sections 1 and 2 above +provided that you also do one of the following: + +a) Accompany it with the complete corresponding machine-readable source code, +which must be distributed under the terms of Sections 1 and 2 above on a medium +customarily used for software interchange; or, + +b) Accompany it with a written offer, valid for at least three years, to give +any third party, for a charge no more than your cost of physically performing +source distribution, a complete machine-readable copy of the corresponding +source code, to be distributed under the terms of Sections 1 and 2 above on +a medium customarily used for software interchange; or, + +c) Accompany it with the information you received as to the offer to distribute +corresponding source code. (This alternative is allowed only for noncommercial +distribution and only if you received the program in object code or executable +form with such an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for making +modifications to it. For an executable work, complete source code means all +the source code for all modules it contains, plus any associated interface +definition files, plus the scripts used to control compilation and installation +of the executable. However, as a special exception, the source code distributed +need not include anything that is normally distributed (in either source or +binary form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component itself +accompanies the executable. + +If distribution of executable or object code is made by offering access to +copy from a designated place, then offering equivalent access to copy the +source code from the same place counts as distribution of the source code, +even though third parties are not compelled to copy the source along with +the object code. + +4. You may not copy, modify, sublicense, or distribute the Program except +as expressly provided under this License. Any attempt otherwise to copy, modify, +sublicense or distribute the Program is void, and will automatically terminate +your rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses terminated +so long as such parties remain in full compliance. + +5. You are not required to accept this License, since you have not signed +it. However, nothing else grants you permission to modify or distribute the +Program or its derivative works. These actions are prohibited by law if you +do not accept this License. Therefore, by modifying or distributing the Program +(or any work based on the Program), you indicate your acceptance of this License +to do so, and all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the Program), +the recipient automatically receives a license from the original licensor +to copy, distribute or modify the Program subject to these terms and conditions. +You may not impose any further restrictions on the recipients' exercise of +the rights granted herein. You are not responsible for enforcing compliance +by third parties to this License. + +7. If, as a consequence of a court judgment or allegation of patent infringement +or for any other reason (not limited to patent issues), conditions are imposed +on you (whether by court order, agreement or otherwise) that contradict the +conditions of this License, they do not excuse you from the conditions of +this License. If you cannot distribute so as to satisfy simultaneously your +obligations under this License and any other pertinent obligations, then as +a consequence you may not distribute the Program at all. For example, if a +patent license would not permit royalty-free redistribution of the Program +by all those who receive copies directly or indirectly through you, then the +only way you could satisfy both it and this License would be to refrain entirely +from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply and +the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents +or other property right claims or to contest validity of any such claims; +this section has the sole purpose of protecting the integrity of the free +software distribution system, which is implemented by public license practices. +Many people have made generous contributions to the wide range of software +distributed through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing to +distribute software through any other system and a licensee cannot impose +that choice. + +This section is intended to make thoroughly clear what is believed to be a +consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in certain +countries either by patents or by copyrighted interfaces, the original copyright +holder who places the Program under this License may add an explicit geographical +distribution limitation excluding those countries, so that distribution is +permitted only in or among countries not thus excluded. In such case, this +License incorporates the limitation as if written in the body of this License. + +9. The Free Software Foundation may publish revised and/or new versions of +the General Public License from time to time. Such new versions will be similar +in spirit to the present version, but may differ in detail to address new +problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies +a version number of this License which applies to it and "any later version", +you have the option of following the terms and conditions either of that version +or of any later version published by the Free Software Foundation. If the +Program does not specify a version number of this License, you may choose +any version ever published by the Free Software Foundation. + +10. If you wish to incorporate parts of the Program into other free programs +whose distribution conditions are different, write to the author to ask for +permission. For software which is copyrighted by the Free Software Foundation, +write to the Free Software Foundation; we sometimes make exceptions for this. +Our decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing and reuse +of software generally. + + NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR +THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE +STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM +"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA +OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES +OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively convey the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +< one line to give the program's name and an idea of what it does. > + +Copyright (C) < yyyy > < name of author > + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 2 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +this program; if not, write to the Free Software Foundation, Inc., 51 Franklin +Street, Fifth Floor, Boston, MA 02110-1301 , USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this when +it starts in an interactive mode: + +Gnomovision version 69, Copyright (C) year name of author Gnomovision comes +with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, +and you are welcome to redistribute it under certain conditions; type `show +c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may be +called something other than `show w' and `show c'; they could even be mouse-clicks +or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your school, +if any, to sign a "copyright disclaimer" for the program, if necessary. Here +is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' +(which makes passes at compilers) written by James Hacker. + +< signature of Ty Coon > , 1 April 1989 Ty Coon, President of Vice This General +Public License does not permit incorporating your program into proprietary +programs. If your program is a subroutine library, you may consider it more +useful to permit linking proprietary applications with the library. If this +is what you want to do, use the GNU Lesser General Public License instead +of this License. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..89f2aa2 --- /dev/null +++ b/Makefile @@ -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 twitchsingstools . diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee21417 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# karaokards + +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) + diff --git a/build/ci/drone.yml b/build/ci/drone.yml new file mode 100644 index 0000000..f1c3af9 --- /dev/null +++ b/build/ci/drone.yml @@ -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/twitchsingstools + - cd /go/src/git.martyn.berlin/martyn/twitchsingstools + - 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/twitchsingstools + - cd /go/src/git.martyn.berlin/martyn/twitchsingstools + - go get + - go build + +trigger: + ref: + - refs/heads/devel + - refs/heads/master diff --git a/build/package/Dockerfile b/build/package/Dockerfile new file mode 100755 index 0000000..f4f4a15 --- /dev/null +++ b/build/package/Dockerfile @@ -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/twitchsingstools/ +COPY internal/ /go/src/git.martyn.berlin/martyn/twitchsingstools/internal/ +COPY Makefile /go/src/git.martyn.berlin/martyn/twitchsingstools/ +RUN cd /go/src/git.martyn.berlin/martyn/twitchsingstools/; 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/twitchsingstools /app/ +COPY strings.json /app/strings.json +COPY web/ /app/web/ +WORKDIR /app +CMD ["/app/twitchsingstools"] diff --git a/configs/config.json b/configs/config.json new file mode 100755 index 0000000..e35eac8 --- /dev/null +++ b/configs/config.json @@ -0,0 +1,4 @@ +{ + "channels": ["karaokards"], + "externalUrl": "karaokards.ing.martyn.berlin" +} \ No newline at end of file diff --git a/data/channelData/imartynonbot.json b/data/channelData/imartynonbot.json new file mode 100644 index 0000000..1ce00c5 --- /dev/null +++ b/data/channelData/imartynonbot.json @@ -0,0 +1,6 @@ +{ + "name": "imartynonbot", + "customcommand": "card", + "jointime": "2020-02-22T14:20:44.372767247+01:00", + "ControlChannel": true +} \ No newline at end of file diff --git a/data/channelData/imartynontwitch.json b/data/channelData/imartynontwitch.json new file mode 100644 index 0000000..84d88fc --- /dev/null +++ b/data/channelData/imartynontwitch.json @@ -0,0 +1,7 @@ +{ + "name": "imartynontwitch", + "value": "MzdmY2MwYzEtN2FlMC00ZTQ0LThkZDUtZDFhZDljZWUwMjZj", + "customcommand": "card", + "extrastrings": "Make Pineboy Choose!", + "jointime": "2020-02-22T13:54:06.31158195+01:00" +} \ No newline at end of file diff --git a/data/channelData/karaokards.json b/data/channelData/karaokards.json new file mode 100644 index 0000000..6cc820d --- /dev/null +++ b/data/channelData/karaokards.json @@ -0,0 +1,7 @@ +{ + "name": "karaokards", + "customcommand": "card", + "jointime": "2020-05-16T13:25:55.339717754+02:00", + "ControlChannel": false, + "hasleft": false +} \ No newline at end of file diff --git a/data/prompts/common.json b/data/prompts/common.json new file mode 100644 index 0000000..ec747fa --- /dev/null +++ b/data/prompts/common.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/deployments/helm/twitchsingstools/.helmignore b/deployments/helm/twitchsingstools/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/deployments/helm/twitchsingstools/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deployments/helm/twitchsingstools/Chart.yaml b/deployments/helm/twitchsingstools/Chart.yaml new file mode 100644 index 0000000..cee8718 --- /dev/null +++ b/deployments/helm/twitchsingstools/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: twitchsingstools +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. +appVersion: 1.16.0 diff --git a/deployments/helm/twitchsingstools/templates/NOTES.txt b/deployments/helm/twitchsingstools/templates/NOTES.txt new file mode 100644 index 0000000..bffabd1 --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "twitchsingstools.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "twitchsingstools.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "twitchsingstools.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "twitchsingstools.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/deployments/helm/twitchsingstools/templates/_helpers.tpl b/deployments/helm/twitchsingstools/templates/_helpers.tpl new file mode 100644 index 0000000..ec5394c --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "twitchsingstools.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "twitchsingstools.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "twitchsingstools.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "twitchsingstools.labels" -}} +helm.sh/chart: {{ include "twitchsingstools.chart" . }} +{{ include "twitchsingstools.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "twitchsingstools.selectorLabels" -}} +app.kubernetes.io/name: {{ include "twitchsingstools.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "twitchsingstools.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "twitchsingstools.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/deployments/helm/twitchsingstools/templates/configmap.yaml b/deployments/helm/twitchsingstools/templates/configmap.yaml new file mode 100644 index 0000000..4f129f9 --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: tstoolsconfig + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} +data: + config.json: {{ printf "{ \"externalUrl\": \"%s\" }" .Values.externalHostname | quote }} diff --git a/deployments/helm/twitchsingstools/templates/deployment.yaml b/deployments/helm/twitchsingstools/templates/deployment.yaml new file mode 100644 index 0000000..dd8d9de --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "twitchsingstools.fullname" . }} + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "twitchsingstools.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "twitchsingstools.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "twitchsingstools.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - mountPath: /etc/tstools/ + name: oauth + - mountPath: /app/config.json + name: config + subPath: config.json + - mountPath: /data + name: data + volumes: + - name: oauth + secret: + defaultMode: 420 + secretName: twitchoauth + - name: config + configMap: + defaultMode: 420 + items: + - key: config.json + path: config.json + name: tstoolsconfig + - name: data + persistentVolumeClaim: + claimName: tstools-data + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deployments/helm/twitchsingstools/templates/ingress.yaml b/deployments/helm/twitchsingstools/templates/ingress.yaml new file mode 100644 index 0000000..14c48d0 --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "twitchsingstools.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deployments/helm/twitchsingstools/templates/pvc.yaml b/deployments/helm/twitchsingstools/templates/pvc.yaml new file mode 100644 index 0000000..66bcc1d --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/pvc.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "twitchsingstools.serviceAccountName" . }} + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} diff --git a/deployments/helm/twitchsingstools/templates/secret.yaml b/deployments/helm/twitchsingstools/templates/secret.yaml new file mode 100644 index 0000000..5cd7944 --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/secret.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: twitchoauth + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} +type: Opaque +data: + ircoauth.json: {{ printf "{ \"nick\": \"%s\", \"password\": \"%s\" }" .Values.irc.nick .Values.irc.password | b64enc | quote }} + appoauth.json: {{ printf "{ \"client_secret\": \"%s\", \"client_id\": \"%s\" }" .Values.twitchapp.client_secret .Values.twitchapp.client_id | b64enc | quote }} + {{- range $key, $value := .Values.secretFiles }} + {{ $key }} : {{ $value | b64enc | quote }} + {{- end }} diff --git a/deployments/helm/twitchsingstools/templates/service.yaml b/deployments/helm/twitchsingstools/templates/service.yaml new file mode 100644 index 0000000..221863c --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "twitchsingstools.fullname" . }} + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "twitchsingstools.selectorLabels" . | nindent 4 }} diff --git a/deployments/helm/twitchsingstools/templates/serviceaccount.yaml b/deployments/helm/twitchsingstools/templates/serviceaccount.yaml new file mode 100644 index 0000000..66bcc1d --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "twitchsingstools.serviceAccountName" . }} + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} diff --git a/deployments/helm/twitchsingstools/templates/tests/test-connection.yaml b/deployments/helm/twitchsingstools/templates/tests/test-connection.yaml new file mode 100644 index 0000000..d0de904 --- /dev/null +++ b/deployments/helm/twitchsingstools/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "twitchsingstools.fullname" . }}-test-connection" + labels: + {{- include "twitchsingstools.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "twitchsingstools.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/deployments/helm/twitchsingstools/values.secret.yaml b/deployments/helm/twitchsingstools/values.secret.yaml new file mode 100644 index 0000000..a43201a --- /dev/null +++ b/deployments/helm/twitchsingstools/values.secret.yaml @@ -0,0 +1,32 @@ +irc: + password: ENC[AES256_GCM,data:bmVyAcxlCKaL8MkttQWipktXkoU637pSRuEZHETvkFBdRX4p,iv:NSZ6TtlXcxy7cUtKZ1MrB4hAX4FwNV5LuPNViFd0jh0=,tag:4Ex7p59w2FFkFkp4yEhZ0w==,type:str] +twitchapp: + client_id: ENC[AES256_GCM,data:Iy1Wb5WevXQtDpDPTKKkpYez+WWOGOUDre89qf1Y,iv:z/ZbcgADO5vDQOjX/A6xU99lGiD2k3qxor31KnVFE4o=,tag:pgf67HdxOjUSWEktgPfolw==,type:str] + client_secret: ENC[AES256_GCM,data:5fY77ccyfqZMh30hMdhXy1h7fio4763NLgYu9tgP,iv:kLyEULWcgKHwpPxoBX69IcHAaeAob9AN8gD5/K5nIyg=,tag:nxtZMuCZdGyVhBBvgRz2SA==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + lastmodified: '2020-07-14T18:03:02Z' + mac: ENC[AES256_GCM,data:quUrWcjviC08daW0k+Y0a6oB+ZXT8NkF2z1cni2DeFUuSXjrxDQcMY3POj45DbzHrojXMkJKOk6JLOcj9oV1h8uCqyZ/rGWhgfly1YNLJEP8LcJbCQt2jUAhctxaQkvPtqIak7e/ABKHhMuKmBbNWzhTLBbyl2/YR2+pPsLxKQA=,iv:PbRPKBaLtns4+M0ydrVTecVHgtR3q7TkhKWN01cEhVE=,tag:nCK7gSQ+F97lJJhCOol7zw==,type:str] + pgp: + - created_at: '2020-07-14T18:02:04Z' + enc: | + -----BEGIN PGP MESSAGE----- + + hQGMA/w0sxTgx97RAQv+IVeVm2yGUv4++uJ5BEy7fZQLHsi/k6C8XxVX9rm9WjSa + pPPovDHJUJ0BDigtWRvKsQLh8ahIv3I5hQzFl2oQNjOSQmMyK6yr5XGWhyFtKRUu + SSQrnPh5NJgZXD3cnG1/jMTf5Hbdj4CFM/TZuc0tW9oot5iNpL5u8jXQhfQ+ckj1 + CtHB65H/TEjlEe/I8MB+ijqbnmSRyaDZKrdx97xbJZbk0HLqwCXIFOhSd182TjXd + v+qrXtHB790pUuJNcZtyfwOfJldhwBXmHW4As4EllZ4d2WW5g13undMyE1qg4dBj + EguI9Vze/hAWf48X4IRmJTNXECQkz3+MutINQIPBpCgZtdz8AJghwVPSHvS2G0de + esBULkV1C48nj2ndISk4OsNtCwU7PfNdQzySckBtBKhd+av3EvFJqPDTJGnkGwD6 + 9dlhVD5nL3ZwbzM8Y7LwAg5l0ID1ITvUW/T9Ngqnxz8cirJDDawyS/TQll5gZIrV + lsEE65wHrf5J3bm4CY2D0l4B3tySeDQ0daZQWMUKiloTlNpffDcmAdNUBLH+IPYa + LRgRzqCZWJgojwrub2lCT03Ksw7SLFn1b77rxqMnqjP4w0sPq04iGz5U6L06oJ32 + clkhMGk6hdaCW+fk+VBa + =OesV + -----END PGP MESSAGE----- + fp: 96D854EEBF3057E6A059C2D3091E073506551185 + unencrypted_suffix: _unencrypted + version: 3.5.0 diff --git a/deployments/helm/twitchsingstools/values.yaml b/deployments/helm/twitchsingstools/values.yaml new file mode 100644 index 0000000..24078ba --- /dev/null +++ b/deployments/helm/twitchsingstools/values.yaml @@ -0,0 +1,76 @@ +# Default values for twitchsingstools. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: twitchsingstools + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +secretFiles: {} + +irc: + nick: twitchsingstools + +twitchapp: {} + +service: + type: ClusterIP + port: 80 + +externalHostname: twitchsingstools.ing.martyn.berlin + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + hosts: + - host: twitchsingstools.ing.martyn.berlin + paths: [] + tls: + - secretName: tstools-tls + hosts: + - twitchsingstools.ing.martyn.berlin + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/internal/builtins/kards.go b/internal/builtins/kards.go new file mode 100644 index 0000000..93257d5 --- /dev/null +++ b/internal/builtins/kards.go @@ -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, 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"} diff --git a/internal/irc/irc.go b/internal/irc/irc.go new file mode 100644 index 0000000..849cebe --- /dev/null +++ b/internal/irc/irc.go @@ -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) +} diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go new file mode 100755 index 0000000..41fb382 --- /dev/null +++ b/internal/webserver/webserver.go @@ -0,0 +1,354 @@ +package webserver + +import ( + "math/rand" + + irc "git.martyn.berlin/martyn/twitchsingstools/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") + 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.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") + 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= + // &client_secret= + // &code= + // &grant_type=authorization_code + // &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() +} diff --git a/main.go b/main.go new file mode 100755 index 0000000..f44ae10 --- /dev/null +++ b/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "time" + + builtins "git.martyn.berlin/martyn/twitchsingstools/internal/builtins" + irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc" + webserver "git.martyn.berlin/martyn/twitchsingstools/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("TSTOOLS_CONFIGFILE") != "" { + if _, err := os.Stat(os.Getenv("TSTOOLS_CONFIGFILE")); os.IsNotExist(err) { + rgb.RPrintf("[%s] Error, TSTOOLS_CONFIGFILE env var set and '%s' doesn't exist!\n", irc.TimeStamp(), os.Getenv("TSTOOLS_CONFIGFILE")) + os.Exit(1) + } + configFile = os.Getenv("TSTOOLS_CONFIGFILE") + } else { + ex, err := os.Executable() + if err != nil { + rgb.YPrintf("[%s] Warning, TSTOOLS_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, TSTOOLS_CONFIGFILE env var unset and `config.json` not alongside executable!\n", irc.TimeStamp()) + if _, err := os.Stat("/etc/tstools/config.json"); os.IsNotExist(err) { + rgb.RPrintf("[%s] Error, TSTOOLS_CONFIGFILE env var unset and neither '%s' nor '%s' exist!\n", irc.TimeStamp(), exPath+"/config.json", "/etc/tstools/config.json") + os.Exit(1) + } else { + configFile = "/etc/tstools/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("TSTOOLS_DATA_FOLDER") != "" { + if _, err := os.Stat(os.Getenv("TSTOOLS_DATA_FOLDER")); os.IsNotExist(err) { + rgb.RPrintf("[%s] Error, TSTOOLS_DATA_FOLDER env var set and '%s' doesn't exist!\n", irc.TimeStamp(), os.Getenv("TSTOOLS_DATA_FOLDER")) + os.Exit(1) + } + dataPath = os.Getenv("TSTOOLS_DATA_FOLDER") + } else { + ex, err := os.Executable() + if err != nil { + rgb.RPrintf("[%s] Error, TSTOOLS_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 +} + +var buildDate string + +func main() { + rgb.YPrintf("[%s] starting twitchsingstools bot build %s\n", irc.TimeStamp(), buildDate) + readConfig() + rand.Seed(time.Now().UnixNano()) + for _, val := range builtins.Karaokards { + 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("TSTOOLS_OAUTH_JSON") != "" { + if _, err := os.Stat(os.Getenv("TSTOOLS_OAUTH_JSON")); os.IsNotExist(err) { + os.Exit(1) + } + ircOauthPath = os.Getenv("TSTOOLS_OAUTH_JSON") + } else { + if _, err := os.Stat(os.Getenv("HOME") + "/.tstools/ircoauth.json"); os.IsNotExist(err) { + rgb.YPrintf("[%s] Warning %s doesn't exist, trying %s next!\n", irc.TimeStamp(), os.Getenv("HOME")+"/.tstools/ircoauth.json", "/etc/tstools/ircoauth.json") + if _, err := os.Stat("/etc/tstools/ircoauth.json"); os.IsNotExist(err) { + rgb.YPrintf("[%s] Error %s doesn't exist either, cannot connect to irc!\n", irc.TimeStamp(), "/etc/tstools/ircoauth.json") + } else { + ircOauthPath = "/etc/tstools/ircoauth.json" + } + } else { + ircOauthPath = os.Getenv("HOME") + "/.tstools/ircoauth.json" + } + } + } else { + if _, err := os.Stat(config.IrcOAuthPath); os.IsNotExist(err) { + rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, cannot connect to irc!\n", irc.TimeStamp(), config.IrcOAuthPath) + } else { + ircOauthPath = config.IrcOAuthPath + } + } + appOauthPath := "" + if config.AppOAuthPath == "" { + if os.Getenv("TSTOOLS_OAUTH_JSON") != "" { + if _, err := os.Stat(os.Getenv("TSTOOLS_OAUTH_JSON")); os.IsNotExist(err) { + os.Exit(1) + } + appOauthPath = os.Getenv("TSTOOLS_OAUTH_JSON") + } else { + if _, err := os.Stat(os.Getenv("HOME") + "/.tstools/appoauth.json"); os.IsNotExist(err) { + rgb.YPrintf("[%s] Warning %s doesn't exist, trying %s next!\n", irc.TimeStamp(), os.Getenv("HOME")+"/.tstools/appoauth.json", "/etc/tstools/appoauth.json") + if _, err := os.Stat("/etc/tstools/appoauth.json"); os.IsNotExist(err) { + rgb.YPrintf("[%s] Error %s doesn't exist either, bailing!\n", irc.TimeStamp(), "/etc/tstools/appoauth.json") + os.Exit(1) + } + appOauthPath = "/etc/tstools/appoauth.json" + } else { + appOauthPath = os.Getenv("HOME") + "/.tstools/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. + var myBot irc.KardBot + if ircOauthPath != "" { + myBot = irc.KardBot{ + Channel: "twitchsingstools", + MsgRate: time.Duration(20/30) * time.Millisecond, + Name: "twitchsingstools", + Port: "6667", + IrcPrivatePath: ircOauthPath, + AppPrivatePath: appOauthPath, + Server: "irc.chat.twitch.tv", + Prompts: selectablePrompts, + Database: *persistentData, + Config: config, + } + myBot.Start() + } + go func() { + rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353") + webserver.HandleHTTP(&myBot) + }() +} diff --git a/web/401.html b/web/401.html new file mode 100755 index 0000000..80bc8a8 --- /dev/null +++ b/web/401.html @@ -0,0 +1,107 @@ + + + + + + + + + The great unknown! + + + + + + + + + +
+
+
+

k8s-zoo

+ +
+
+
+

Scary user alert!

+ Cat sat outside a door that has a sign depictinging no cats allowed +

It seems you've gone somewhere you shouldn't! 401 NOT AUTHORIZED!

+

+

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.

+

Shameless self-promotion : Follow me on twitch - iMartynOnTwitch, oddly enough, I do a lot of twitchsings!

+
+ +
+ + + + + \ No newline at end of file diff --git a/web/404.html b/web/404.html new file mode 100644 index 0000000..d225779 --- /dev/null +++ b/web/404.html @@ -0,0 +1,106 @@ + + + + + + + + + The great unknown! + + + + + + + + + +
+
+
+

k8s-zoo

+ +
+
+
+

Ooops!

+

It seems you've gone somewhere you shouldn't! 404 NOT FOUND!

+

+

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.

+

Shameless self-promotion : Follow me on twitch - iMartynOnTwitch, oddly enough, I do a lot of twitchsings!

+
+ +
+ + + + + \ No newline at end of file diff --git a/web/admin.html b/web/admin.html new file mode 100755 index 0000000..1586723 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,143 @@ + + + + + + + + + Karaokards + + + + + + + + + +
+
+
+

Karaokards

+ +
+
+
+ + +

Karaokards admin panel for {{.Channel}}!!!

+
+ {{ if .HasLeft }} +

Not in your channel at the moment!

+

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.

+

You can invite the bot to your channel by clicking here :

+ {{ else }} + {{ if .Leaving }} +

Do you really want this bot to leave your channel?

+

+ {{ else }} +

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.

+ + + + + + + + + + +
Channel Data :
Member of channel since {{.SinceTimeUTC}}
Command for prompt:{{.Command}}
Extra prompts (one per line):{{.ExtraStrings}}
 
Or... please don't go but...
+ {{ end }} + {{ end }} +
+
+ +
+ + + + \ No newline at end of file diff --git a/web/bye.html b/web/bye.html new file mode 100755 index 0000000..093bed8 --- /dev/null +++ b/web/bye.html @@ -0,0 +1,75 @@ + + + + + + + + + Karaokards + + + + + + + + + +
+
+
+

Karaokards

+ +
+
+
+

Left channel!!!

+

animation of dissappointed sister from Frozen saying Okay, bye...

+
+ +
+ + + + \ No newline at end of file diff --git a/web/cover.css b/web/cover.css new file mode 100644 index 0000000..b275c1c --- /dev/null +++ b/web/cover.css @@ -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); +} \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3f4395b --- /dev/null +++ b/web/index.html @@ -0,0 +1,79 @@ + + + + + + + + + Twitch Sings Tools + + + + + + + + + +
+
+
+

Twitch Sings Tools

+

(Not endorsed by Twitch, the app is called "Twitch Sings" and these are tools for it, whaddya want it to be called?!)

+ +
+
+
+

Karaokards!!!

+

Random prompt for you : {{.Prompt}}

+

There are a total of {{.AvailCount}} prompts available. This bot is hanging out in {{.ChannelCount}} channels and has served {{.MessageCount}} prompts via twitch chat!

+
+ +
+ + + + diff --git a/web/standby.html b/web/standby.html new file mode 100755 index 0000000..32c9c5c --- /dev/null +++ b/web/standby.html @@ -0,0 +1,111 @@ + + + + + + + + + Please Stand By! + + + + + + + + + +
+
+
+

Karaokards

+ +
+
+
+

Please stand by, twitch gives us stuff that needs to be sent to the server!

+ Cats typing furiously +

+

Just hold on, the javascript is doing it's stuff. Don't blame me, twitch really forces us to use javascript here.

+

Shameless self-promotion : Follow me on twitch - iMartynOnTwitch, oddly enough, I do a lot of twitchsings!

+
+ +
+ + + + + \ No newline at end of file diff --git a/web/static/okaybye.gif b/web/static/okaybye.gif new file mode 100755 index 0000000..2267da9 Binary files /dev/null and b/web/static/okaybye.gif differ