feat: remove kivy frontend, add litestar
This commit is contained in:
parent
b71bedb62a
commit
6b873348c7
48 changed files with 3092 additions and 800 deletions
3
.envrc
Normal file
3
.envrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
|
||||||
|
|
||||||
|
use devenv
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
|
|
@ -110,3 +110,22 @@ reportlog.json
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.pdm.toml
|
.pdm.toml
|
||||||
requirements.txt
|
requirements.txt
|
||||||
|
src/huesoporro/tts_files/
|
||||||
|
# Devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
# Devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
files: src|tests
|
||||||
|
exclude: ^$
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.6.0
|
rev: v4.6.0
|
||||||
|
|
@ -16,21 +18,40 @@ repos:
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args: [ --fix=lf ]
|
args: [ --fix=lf ]
|
||||||
|
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
|
||||||
rev: v0.6.4
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args:
|
|
||||||
- --fix
|
|
||||||
- --exit-non-zero-on-fix
|
|
||||||
- id: ruff-format
|
|
||||||
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: mypy
|
name: mypy
|
||||||
entry: uv run mypy
|
entry: uv run mypy --check-untyped-defs
|
||||||
language: system
|
language: system
|
||||||
types: [ python ]
|
types: [ python ]
|
||||||
|
exclude: LICENSE|helm
|
||||||
|
exclude_types:
|
||||||
|
- markdown
|
||||||
|
- css
|
||||||
|
- html
|
||||||
|
|
||||||
|
- id: ruff-format
|
||||||
|
name: ruff format
|
||||||
|
language: system
|
||||||
|
entry: ruff format .
|
||||||
|
exclude: LICENSE|charts
|
||||||
|
exclude_types:
|
||||||
|
- markdown
|
||||||
|
- css
|
||||||
|
- html
|
||||||
|
- javascript
|
||||||
|
|
||||||
|
- id: ruff-check
|
||||||
|
name: ruff check
|
||||||
|
language: system
|
||||||
|
entry: ruff check . --fix --exit-non-zero-on-fix
|
||||||
|
exclude: LICENSE|charts
|
||||||
|
exclude_types:
|
||||||
|
- markdown
|
||||||
|
- css
|
||||||
|
- html
|
||||||
|
- javascript
|
||||||
|
|
||||||
|
|
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# hadolint ignore=DL3006,DL3007
|
||||||
|
FROM cgr.dev/chainguard/wolfi-base:latest AS base
|
||||||
|
|
||||||
|
SHELL ["/bin/ash", "-ex", "-c"]
|
||||||
|
|
||||||
|
ARG USERID=1000
|
||||||
|
ARG GROUPID=1000
|
||||||
|
|
||||||
|
ENV USERNAME="huesoporro"
|
||||||
|
ENV APP_HOME="/home/$USERNAME"
|
||||||
|
ENV APP_PATH="$APP_HOME"
|
||||||
|
|
||||||
|
ENV POETRY_VERSION=1.8.3
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONPATH="$APP_PATH"
|
||||||
|
ENV PATH="$APP_HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# hadolint ignore=DL3001,DL3008,DL3018
|
||||||
|
RUN apk add --no-cache make python3~=3.12 \
|
||||||
|
&& adduser -S -u "$USERID" -h "$APP_HOME" "$USERNAME" \
|
||||||
|
&& mkdir -p "$APP_PATH" \
|
||||||
|
&& chown -R "$USERID:$GROUPID" "$APP_PATH"
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
USER "$USERNAME"
|
||||||
|
|
||||||
|
WORKDIR "$APP_PATH"
|
||||||
|
|
||||||
|
COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./
|
||||||
|
|
||||||
|
RUN uv sync
|
||||||
|
|
||||||
|
COPY --chown=$USERNAME src/ src/
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS serve
|
||||||
|
|
||||||
|
CMD ["make", "serve"]
|
||||||
11
Makefile
11
Makefile
|
|
@ -1,9 +1,18 @@
|
||||||
|
PROJECT_NAME := "huesoporro"
|
||||||
|
PROJECT_TAG := "latest"
|
||||||
|
PROJECT_TARGET := "serve"
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
uvx pre-commit run --all-files --color always
|
uvx pre-commit run --all-files --color always
|
||||||
|
|
||||||
|
|
||||||
.PHONY: tests
|
.PHONY: tests
|
||||||
tests:
|
tests:
|
||||||
uv run pytest --cov=halig -vv tests --report-log reportlog.json
|
uv run pytest --cov=halig -vv tests --report-log reportlog.json
|
||||||
uv run coverage html
|
uv run coverage html
|
||||||
uv run coverage xml
|
uv run coverage xml
|
||||||
|
|
||||||
|
serve:
|
||||||
|
uv run python -m src.huesoporro.main
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# huesoporro
|
||||||
23
charts/huesoporro/.helmignore
Normal file
23
charts/huesoporro/.helmignore
Normal file
|
|
@ -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/
|
||||||
24
charts/huesoporro/Chart.yaml
Normal file
24
charts/huesoporro/Chart.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
apiVersion: v2
|
||||||
|
name: huesoporro
|
||||||
|
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.
|
||||||
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
|
version: 0.2.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. Versions are not expected to
|
||||||
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
|
# It is recommended to use it with quotes.
|
||||||
|
appVersion: "0.2.0"
|
||||||
22
charts/huesoporro/templates/NOTES.txt
Normal file
22
charts/huesoporro/templates/NOTES.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 }}{{ .path }}
|
||||||
|
{{- 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 "helm.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 its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "helm.fullname" . }}'
|
||||||
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "helm.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 "helm.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
|
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||||
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
{{- end }}
|
||||||
62
charts/huesoporro/templates/_helpers.tpl
Normal file
62
charts/huesoporro/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "helm.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 "helm.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 "helm.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "helm.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "helm.chart" . }}
|
||||||
|
{{ include "helm.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "helm.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "helm.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the service account to use
|
||||||
|
*/}}
|
||||||
|
{{- define "helm.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "helm.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
89
charts/huesoporro/templates/deployment.yaml
Normal file
89
charts/huesoporro/templates/deployment.yaml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "helm.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 8 }}
|
||||||
|
{{- with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "helm.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
{{- if and .Values.persistence.enabled .Values.persistence.volumeOwner.enabled }}
|
||||||
|
initContainers:
|
||||||
|
- name: volume-permissions
|
||||||
|
image: busybox
|
||||||
|
command: [ 'sh', '-c', 'chown -R {{ .Values.persistence.volumeOwner.uid }}:{{ .Values.persistence.volumeOwner.gid }} /data' ]
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /home/huesoporro/.local/share/huesoporro
|
||||||
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
runAsUser: {{ .Values.persistence.volumeOwner.uid }}
|
||||||
|
runAsGroup: {{ .Values.persistence.volumeOwner.gid }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: {{ .Values.secret.existingSecretName }}
|
||||||
|
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "helm.fullname" . }}-data
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
32
charts/huesoporro/templates/hpa.yaml
Normal file
32
charts/huesoporro/templates/hpa.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{{- if .Values.autoscaling.enabled }}
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "helm.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
metrics:
|
||||||
|
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
43
charts/huesoporro/templates/ingress.yaml
Normal file
43
charts/huesoporro/templates/ingress.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{{- if .Values.ingress.enabled -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- 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: {{ .path }}
|
||||||
|
{{- with .pathType }}
|
||||||
|
pathType: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "helm.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
15
charts/huesoporro/templates/pvc.yaml
Normal file
15
charts/huesoporro/templates/pvc.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.fullname" . }}-data
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size }}
|
||||||
|
storageClassName: {{ .Values.persistence.storageClassName }}
|
||||||
|
{{- end }}
|
||||||
15
charts/huesoporro/templates/service.yaml
Normal file
15
charts/huesoporro/templates/service.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "helm.selectorLabels" . | nindent 4 }}
|
||||||
13
charts/huesoporro/templates/serviceaccount.yaml
Normal file
13
charts/huesoporro/templates/serviceaccount.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "helm.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||||
|
{{- end }}
|
||||||
15
charts/huesoporro/templates/tests/test-connection.yaml
Normal file
15
charts/huesoporro/templates/tests/test-connection.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: "{{ include "helm.fullname" . }}-test-connection"
|
||||||
|
labels:
|
||||||
|
{{- include "helm.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
"helm.sh/hook": test
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: wget
|
||||||
|
image: busybox
|
||||||
|
command: ['wget']
|
||||||
|
args: ['{{ include "helm.fullname" . }}:{{ .Values.service.port }}']
|
||||||
|
restartPolicy: Never
|
||||||
138
charts/huesoporro/values.yaml
Normal file
138
charts/huesoporro/values.yaml
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Default values for helm.
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
|
||||||
|
image:
|
||||||
|
repository: git.roboces.dev/catalin/huesoporro
|
||||||
|
# This sets the pull policy for images.
|
||||||
|
pullPolicy: Always
|
||||||
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
|
tag: "0.2.0"
|
||||||
|
|
||||||
|
# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
||||||
|
imagePullSecrets: []
|
||||||
|
# This is to override the chart name.
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
#This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
|
||||||
|
serviceAccount:
|
||||||
|
# Specifies whether a service account should be created
|
||||||
|
create: true
|
||||||
|
# Automatically mount a ServiceAccount's API credentials?
|
||||||
|
automount: 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: ""
|
||||||
|
|
||||||
|
# This is for setting Kubernetes Annotations to a Pod.
|
||||||
|
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
|
||||||
|
podAnnotations: {}
|
||||||
|
# This is for setting Kubernetes Labels to a Pod.
|
||||||
|
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext: {}
|
||||||
|
# fsGroup: 2000
|
||||||
|
|
||||||
|
securityContext: {}
|
||||||
|
# capabilities:
|
||||||
|
# drop:
|
||||||
|
# - ALL
|
||||||
|
# readOnlyRootFilesystem: true
|
||||||
|
# runAsNonRoot: true
|
||||||
|
# runAsUser: 1000
|
||||||
|
|
||||||
|
# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
|
||||||
|
service:
|
||||||
|
# This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
|
||||||
|
type: LoadBalancer
|
||||||
|
# This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
|
||||||
|
port: 8000
|
||||||
|
|
||||||
|
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# kubernetes.io/tls-acme: "true"
|
||||||
|
hosts:
|
||||||
|
- host: chart-example.local
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: ImplementationSpecific
|
||||||
|
tls: []
|
||||||
|
# - secretName: chart-example-tls
|
||||||
|
# hosts:
|
||||||
|
# - chart-example.local
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
|
||||||
|
#This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 100
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
# targetMemoryUtilizationPercentage: 80
|
||||||
|
|
||||||
|
# Additional volumes on the output Deployment definition.
|
||||||
|
volumes: []
|
||||||
|
# - name: foo
|
||||||
|
# secret:
|
||||||
|
# secretName: mysecret
|
||||||
|
# optional: false
|
||||||
|
|
||||||
|
# Additional volumeMounts on the output Deployment definition.
|
||||||
|
volumeMounts: []
|
||||||
|
# - name: foo
|
||||||
|
# mountPath: "/etc/foo"
|
||||||
|
# readOnly: true
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: false
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
size: 10Gi
|
||||||
|
storageClassName: "default"
|
||||||
|
volumeOwner:
|
||||||
|
enabled: true
|
||||||
|
uid: 1000
|
||||||
|
gid: 1000
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
secret:
|
||||||
|
existingSecretName: huesoporro-secrets
|
||||||
116
devenv.lock
Normal file
116
devenv.lock
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1733788855,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "d59fee8696cd48f69cf79f65992269df9891ba86",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733328505,
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"pre-commit-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733477122,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-stable": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733730953,
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "7109b680d161993918b0a126f38bc39763e5a709",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-24.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pre-commit-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733665616,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "pre-commit-hooks.nix",
|
||||||
|
"rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "pre-commit-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"pre-commit-hooks": "pre-commit-hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
22
devenv.nix
Normal file
22
devenv.nix
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{ pkgs, lib, config, inputs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
env.GREET = "devenv";
|
||||||
|
|
||||||
|
packages = [ pkgs.git ];
|
||||||
|
|
||||||
|
languages.python.enable = true;
|
||||||
|
languages.python.uv.enable = true;
|
||||||
|
|
||||||
|
scripts.hello.exec = ''
|
||||||
|
echo hello from $GREET
|
||||||
|
'';
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
hello
|
||||||
|
git --version
|
||||||
|
fish
|
||||||
|
'';
|
||||||
|
|
||||||
|
dotenv.enable = true;
|
||||||
|
}
|
||||||
15
devenv.yaml
Normal file
15
devenv.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
|
|
||||||
|
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||||
|
# allowUnfree: true
|
||||||
|
|
||||||
|
# If you're willing to use a package that's vulnerable
|
||||||
|
# permittedInsecurePackages:
|
||||||
|
# - "openssl-1.1.1w"
|
||||||
|
|
||||||
|
# If you have more than one devenv you can merge them
|
||||||
|
#imports:
|
||||||
|
# - ./backend
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
from kivy_deps import sdl2, glew
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['src\\markovbot_gui\\main.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
noarchive=False,
|
|
||||||
optimize=0,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
*[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)],
|
|
||||||
name='markovbot',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
|
|
@ -1,50 +1,41 @@
|
||||||
[project]
|
[project]
|
||||||
name = "markovbot-gui"
|
name = "huesoporro"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
description = "Markov Chain Bot GUI"
|
description = "Misc Twitch bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "tomaarsen" },
|
|
||||||
{ name = "185504a9", email = "catalin@roboces.dev" }
|
{ name = "185504a9", email = "catalin@roboces.dev" }
|
||||||
]
|
]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kivy[base]>=2.3.0",
|
|
||||||
"nltk>=3.9.1",
|
"nltk>=3.9.1",
|
||||||
"pillow>=10.4.0",
|
|
||||||
"platformdirs>=4.3.6",
|
"platformdirs>=4.3.6",
|
||||||
"pydantic>=2.9.2",
|
"pydantic>=2.9.2",
|
||||||
"pydantic-settings>=2.6.0",
|
"pydantic-settings>=2.6.0",
|
||||||
"pyinstaller>=6.11.0",
|
"pyinstaller>=6.11.0",
|
||||||
"twitchwebsocket>=1.2.1",
|
"twitchwebsocket>=1.2.1",
|
||||||
"loguru>=0.7.2",
|
"loguru>=0.7.2",
|
||||||
|
"ffmpeg>=1.4",
|
||||||
|
"ffmpeg-python>=0.2.0",
|
||||||
|
"gtts>=2.5.4",
|
||||||
|
"litestar[standard]>=2.13.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
dev-dependencies = [
|
dev-dependencies = [
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pyright>=1.1.387",
|
|
||||||
"ruff>=0.7.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"kivy",
|
|
||||||
"kivy.uix.widget",
|
|
||||||
"kivy.uix.popup",
|
|
||||||
"kivy.uix.button",
|
|
||||||
"kivy.uix.boxlayout",
|
|
||||||
"kivy.uix.textinput",
|
|
||||||
"kivy.uix.label",
|
|
||||||
"kivy.metrics",
|
|
||||||
"kivy.app",
|
|
||||||
"kivy.clock",
|
|
||||||
"nltk",
|
"nltk",
|
||||||
"nltk.tokenize",
|
"nltk.tokenize",
|
||||||
"nltk.tokenize.treebank",
|
"nltk.tokenize.treebank",
|
||||||
"nltk.tokenize.destructive",
|
"nltk.tokenize.destructive",
|
||||||
"TwitchWebsocket",
|
"TwitchWebsocket",
|
||||||
"tokenizer"
|
"tokenizer",
|
||||||
|
"gtts"
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|
|
||||||
62
src/huesoporro/chatbot.py
Normal file
62
src/huesoporro/chatbot.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import asyncio
|
||||||
|
from asyncio import sleep as asleep
|
||||||
|
from queue import Queue
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import nltk
|
||||||
|
from litestar import WebSocket
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.huesoporro.libs.markov_chain_bot import MarkovChain
|
||||||
|
from src.huesoporro.libs.settings import Settings as MarkovChainSettings
|
||||||
|
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
|
||||||
|
|
||||||
|
nltk.download("punkt_tab")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatbotManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.bot: MarkovChain | None = None
|
||||||
|
self.clients: set[WebSocket] = set()
|
||||||
|
self.log_queue: Queue = Queue()
|
||||||
|
self.tasks: set = set()
|
||||||
|
|
||||||
|
def start_bot(
|
||||||
|
self,
|
||||||
|
channel_name: str,
|
||||||
|
nickname: str,
|
||||||
|
authentication: str,
|
||||||
|
):
|
||||||
|
task = asyncio.create_task(self.send_bot_status())
|
||||||
|
self.tasks.add(task)
|
||||||
|
if self.bot:
|
||||||
|
return
|
||||||
|
self.bot = MarkovChain(
|
||||||
|
settings=MarkovChainSettings(
|
||||||
|
Channel=channel_name,
|
||||||
|
Nickname=nickname,
|
||||||
|
Authentication=authentication,
|
||||||
|
AutomaticGenerationTimer=300,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bot.run_bot()
|
||||||
|
sleep(2)
|
||||||
|
|
||||||
|
def stop_bot(self):
|
||||||
|
self.bot.stop_bot()
|
||||||
|
self.bot = None
|
||||||
|
|
||||||
|
async def send_bot_status(self):
|
||||||
|
while True:
|
||||||
|
for client in self.clients:
|
||||||
|
message = WebsocketMessage(
|
||||||
|
command=WebsocketCommands.CHATBOT_STATUS,
|
||||||
|
data={"status": "ok" if self.bot else "ko"},
|
||||||
|
)
|
||||||
|
await client.send_text(message.model_dump_json())
|
||||||
|
logger.info(
|
||||||
|
f"Sending bot status {message} to {client.client.host}:{client.client.port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asleep(2)
|
||||||
|
|
@ -88,7 +88,7 @@ class Database:
|
||||||
|
|
||||||
def __init__(self, channel: str):
|
def __init__(self, channel: str):
|
||||||
self.user_data_path = platformdirs.user_data_path(
|
self.user_data_path = platformdirs.user_data_path(
|
||||||
"markovbot_gui",
|
"huesoporro",
|
||||||
ensure_exists=True,
|
ensure_exists=True,
|
||||||
)
|
)
|
||||||
self.db_path = (
|
self.db_path = (
|
||||||
|
|
@ -191,6 +191,7 @@ class Database:
|
||||||
fetch=True,
|
fetch=True,
|
||||||
):
|
):
|
||||||
logger.info("Creating backup before updating Database...")
|
logger.info("Creating backup before updating Database...")
|
||||||
|
|
||||||
# Connect to both the new and backup, backup, and close both
|
# Connect to both the new and backup, backup, and close both
|
||||||
|
|
||||||
def progress(status, remaining, total):
|
def progress(status, remaining, total):
|
||||||
|
|
@ -356,7 +357,7 @@ class Database:
|
||||||
|
|
||||||
from nltk import ngrams
|
from nltk import ngrams
|
||||||
|
|
||||||
from src.markovbot_gui.libs.tokenizer import tokenize
|
from src.huesoporro.libs.tokenizer import tokenize
|
||||||
|
|
||||||
channel = channel.replace("#", "").lower()
|
channel = channel.replace("#", "").lower()
|
||||||
copyfile(
|
copyfile(
|
||||||
|
|
@ -6,10 +6,10 @@ from loguru import logger
|
||||||
from nltk.tokenize import sent_tokenize
|
from nltk.tokenize import sent_tokenize
|
||||||
from TwitchWebsocket import Message, TwitchWebsocket
|
from TwitchWebsocket import Message, TwitchWebsocket
|
||||||
|
|
||||||
from src.markovbot_gui.libs.db import Database
|
from src.huesoporro.libs.db import Database
|
||||||
from src.markovbot_gui.libs.settings import Settings
|
from src.huesoporro.libs.settings import Settings
|
||||||
from src.markovbot_gui.libs.timer import LoopingTimer
|
from src.huesoporro.libs.timer import LoopingTimer
|
||||||
from src.markovbot_gui.libs.tokenizer import detokenize, tokenize
|
from src.huesoporro.libs.tokenizer import detokenize, tokenize
|
||||||
|
|
||||||
|
|
||||||
class Commands(StrEnum):
|
class Commands(StrEnum):
|
||||||
|
|
@ -68,11 +68,12 @@ class MarkovChain:
|
||||||
)
|
)
|
||||||
|
|
||||||
def run_bot(self):
|
def run_bot(self):
|
||||||
self.ws.start_bot()
|
self.ws.start_nonblocking()
|
||||||
|
|
||||||
def stop_bot(self):
|
def stop_bot(self):
|
||||||
self.ws.leave_channel(self.s.channel_name)
|
self.ws.leave_channel(self.s.channel_name)
|
||||||
self.ws.stop()
|
self.ws.stop()
|
||||||
|
logger.info("Stopped bot")
|
||||||
|
|
||||||
def _command_help(self) -> None:
|
def _command_help(self) -> None:
|
||||||
"""Send a Help message to the connected chat, as long as the bot wasn't disabled."""
|
"""Send a Help message to the connected chat, as long as the bot wasn't disabled."""
|
||||||
|
|
@ -249,6 +250,28 @@ class MarkovChain:
|
||||||
|
|
||||||
def message_handler(self, message: Message): # noqa: C901, PLR0911, PLR0912
|
def message_handler(self, message: Message): # noqa: C901, PLR0911, PLR0912
|
||||||
try:
|
try:
|
||||||
|
"""
|
||||||
|
tts_message = {
|
||||||
|
"badge-info": "subscriber/4",
|
||||||
|
"badges": "vip/1,subscriber/3,sub-gifter/5",
|
||||||
|
"color": "#F79AC6",
|
||||||
|
"custom-reward-id": "8c454446-73b0-480f-946e-d6b5f5c5e331",
|
||||||
|
"display-name": "robosap1ens__",
|
||||||
|
"emotes": "",
|
||||||
|
"first-msg": "0",
|
||||||
|
"flags": "",
|
||||||
|
"id": "6cbd37eb-49ae-41f5-b073-345275c91a07",
|
||||||
|
"mod": "0",
|
||||||
|
"returning-chatter": "0",
|
||||||
|
"room-id": "600944302",
|
||||||
|
"subscriber": "1",
|
||||||
|
"tmi-sent-ts": "1733252657689",
|
||||||
|
"turbo": "0",
|
||||||
|
"user-id": "713968248",
|
||||||
|
"user-type": "",
|
||||||
|
"vip": "1",
|
||||||
|
}
|
||||||
|
"""
|
||||||
if not message.user or message.user in self.s.denied_users:
|
if not message.user or message.user in self.s.denied_users:
|
||||||
logger.debug(f"User {message.user} can't send messages")
|
logger.debug(f"User {message.user} can't send messages")
|
||||||
return
|
return
|
||||||
224
src/huesoporro/main.py
Normal file
224
src/huesoporro/main.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import uvicorn
|
||||||
|
from litestar import Litestar, MediaType, Request, Response, WebSocket, get
|
||||||
|
from litestar.connection import ASGIConnection
|
||||||
|
from litestar.contrib.jinja import JinjaTemplateEngine
|
||||||
|
from litestar.datastructures.state import State
|
||||||
|
from litestar.di import Provide
|
||||||
|
from litestar.exceptions import HTTPException
|
||||||
|
from litestar.handlers import BaseRouteHandler, WebsocketListener
|
||||||
|
from litestar.response import Redirect, Template
|
||||||
|
from litestar.static_files import StaticFilesConfig
|
||||||
|
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
from litestar.template import TemplateConfig
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.huesoporro.chatbot import ChatbotManager
|
||||||
|
from src.huesoporro.settings import Settings
|
||||||
|
from src.huesoporro.tts import TTSManager
|
||||||
|
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
|
||||||
|
|
||||||
|
|
||||||
|
async def _authenticate(access_token: str):
|
||||||
|
s = Settings.get()
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
base_url="https://id.twitch.tv",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"}
|
||||||
|
)
|
||||||
|
user_data = resp.json()
|
||||||
|
|
||||||
|
if user_data.get("status"):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
if (user := user_data["login"]) not in s.allowed_users:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
connection: ASGIConnection, route_handler: BaseRouteHandler
|
||||||
|
) -> None:
|
||||||
|
"""Extract cookie from connection and try to authenticate"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = json.loads(connection.cookies.get("twitchLoginData"))
|
||||||
|
except (JSONDecodeError, TypeError) as exc:
|
||||||
|
logger.warning(f"Error parsing twitch login data: {exc}")
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized") from exc
|
||||||
|
|
||||||
|
access_token = login_data.get("access_token")
|
||||||
|
if not login_data or not access_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
user = await _authenticate(access_token)
|
||||||
|
|
||||||
|
connection.state["user"] = user
|
||||||
|
connection.state["access_token"] = access_token
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketHandler(WebsocketListener):
|
||||||
|
path = "/ws"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.tts_manager = TTSManager()
|
||||||
|
self.chatbot_manager = ChatbotManager()
|
||||||
|
self.user = None
|
||||||
|
self.access_token = None
|
||||||
|
|
||||||
|
async def on_accept(self, socket: WebSocket, state: State) -> None:
|
||||||
|
"""If the authentication is correct, add the manager's clients list"""
|
||||||
|
|
||||||
|
cookies = socket.cookies.get("twitchLoginData")
|
||||||
|
try:
|
||||||
|
access_token = json.loads(cookies).get("access_token")
|
||||||
|
except (JSONDecodeError, TypeError) as exc:
|
||||||
|
logger.warning(f"Error parsing twitch login data {exc}")
|
||||||
|
return
|
||||||
|
if not access_token:
|
||||||
|
return
|
||||||
|
user = await _authenticate(access_token)
|
||||||
|
|
||||||
|
self.user = user
|
||||||
|
self.access_token = access_token
|
||||||
|
self.chatbot_manager.clients.add(socket)
|
||||||
|
self.tts_manager.clients.append(socket)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Connection accepted from {socket.client.host}:{socket.client.port}" # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_disconnect(self, socket: WebSocket) -> None:
|
||||||
|
# Remove client from the list
|
||||||
|
if socket in self.tts_manager.clients:
|
||||||
|
self.tts_manager.clients.remove(socket)
|
||||||
|
self.chatbot_manager.clients.remove(socket)
|
||||||
|
logger.info(f"Connection closed by {socket.client.host}:{socket.client.port}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
async def on_receive(self, data: str, state: State) -> None:
|
||||||
|
message = WebsocketMessage(**json.loads(data))
|
||||||
|
logger.info(f"Received {message.command.value} command")
|
||||||
|
|
||||||
|
match message.command:
|
||||||
|
case WebsocketCommands.TTS_SEND:
|
||||||
|
await self.tts_manager.add_to_queue(**message.data)
|
||||||
|
case WebsocketCommands.CHATBOT_START:
|
||||||
|
self.chatbot_manager.start_bot(
|
||||||
|
**message.data
|
||||||
|
| {
|
||||||
|
"nickname": self.user,
|
||||||
|
"authentication": f"oauth:{self.access_token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
case WebsocketCommands.CHATBOT_STOP:
|
||||||
|
self.chatbot_manager.stop_bot()
|
||||||
|
|
||||||
|
|
||||||
|
@get(
|
||||||
|
"/tts",
|
||||||
|
media_type=MediaType.HTML,
|
||||||
|
guards=[authenticate],
|
||||||
|
)
|
||||||
|
async def get_tts_overlay() -> Template:
|
||||||
|
return Template(template_name="tts.html")
|
||||||
|
|
||||||
|
|
||||||
|
@get(
|
||||||
|
"/",
|
||||||
|
media_type=MediaType.HTML,
|
||||||
|
guards=[authenticate],
|
||||||
|
)
|
||||||
|
async def get_index() -> Template:
|
||||||
|
return Template(
|
||||||
|
template_name="index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@get("/login", media_type=MediaType.HTML, dependencies={"s": Provide(Settings.get)})
|
||||||
|
async def login(s: Settings) -> Template:
|
||||||
|
scopes = "+".join(s.twitch_scopes)
|
||||||
|
return Template(
|
||||||
|
"login.html",
|
||||||
|
context={
|
||||||
|
"twitch_login_url": "https://id.twitch.tv/oauth2/authorize?response_type=token"
|
||||||
|
f"&client_id={s.twitch_client_id}"
|
||||||
|
f"&redirect_uri={s.server_hostname}login"
|
||||||
|
f"&scope={scopes}"
|
||||||
|
f"&state={secrets.token_urlsafe(32)}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@get("/healthz")
|
||||||
|
def get_health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def exception_handler(_: Request, exc: Exception) -> Response:
|
||||||
|
status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
detail = getattr(exc, "detail", "")
|
||||||
|
|
||||||
|
if isinstance(exc, HTTPException) and (exc.status_code in [401, 403]):
|
||||||
|
logger.warning("User could not authenticate. Redirecting to /login page")
|
||||||
|
return Redirect("/login")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
media_type=MediaType.TEXT,
|
||||||
|
content=detail,
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def after_exception_handler(exc: Exception, scope: "Scope") -> None:
|
||||||
|
"""Hook function that will be invoked after each exception."""
|
||||||
|
state = scope["app"].state
|
||||||
|
if not hasattr(state, "error_count"):
|
||||||
|
state.error_count = 1
|
||||||
|
else:
|
||||||
|
state.error_count += 1
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"an exception of type {type(exc).__name__} has occurred for requested path {scope['path']} and the application error count is {state.error_count}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
return Litestar(
|
||||||
|
route_handlers=[
|
||||||
|
get_health,
|
||||||
|
login,
|
||||||
|
get_index,
|
||||||
|
get_tts_overlay,
|
||||||
|
WebsocketHandler,
|
||||||
|
],
|
||||||
|
static_files_config=(
|
||||||
|
StaticFilesConfig(
|
||||||
|
path="/tts_files",
|
||||||
|
directories=[Settings.get().tts_cache_path],
|
||||||
|
),
|
||||||
|
StaticFilesConfig(
|
||||||
|
path="static",
|
||||||
|
directories=[Settings.get().static_files_path],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
template_config=TemplateConfig(
|
||||||
|
directory=Settings.get().templates_files_path,
|
||||||
|
engine=JinjaTemplateEngine,
|
||||||
|
),
|
||||||
|
exception_handlers={HTTPException: exception_handler},
|
||||||
|
after_exception=[after_exception_handler],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
settings = Settings.get()
|
||||||
|
app = create_app()
|
||||||
|
uvicorn.run(app, host=settings.host, port=settings.port)
|
||||||
48
src/huesoporro/settings.py
Normal file
48
src/huesoporro/settings.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field, HttpUrl, field_validator
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
port: int = 8000
|
||||||
|
host: str = "0.0.0.0" # noqa: S104
|
||||||
|
static_files_path: Path = Field(
|
||||||
|
default_factory=lambda: Path(__file__).parent / "static"
|
||||||
|
)
|
||||||
|
templates_files_path: Path = Field(
|
||||||
|
default_factory=lambda: Path(__file__).parent / "templates"
|
||||||
|
)
|
||||||
|
tts_cache_path: Path = Field(
|
||||||
|
default_factory=lambda: Path(__file__).parent / "tts_files"
|
||||||
|
)
|
||||||
|
db_filepath: Path = Field(
|
||||||
|
default_factory=lambda: Path(__file__).parent / "huesoporro.db"
|
||||||
|
)
|
||||||
|
twitch_client_id: str
|
||||||
|
twitch_scopes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"]
|
||||||
|
)
|
||||||
|
allowed_users: list[str] | str = Field(default_factory=lambda: ["huesoporro"])
|
||||||
|
server_hostname: HttpUrl = "http://localhost:8000"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get():
|
||||||
|
return Settings() # type: ignore[call-arg] # pydantic-setting magic
|
||||||
|
|
||||||
|
@field_validator("allowed_users")
|
||||||
|
@classmethod
|
||||||
|
def validate_allowed_users(cls, value: list[str] | str):
|
||||||
|
# Convert string to list if necessary
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(",")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("tts_cache_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_tts_cache_path(cls, value: Path):
|
||||||
|
# create path if it doesn't exist
|
||||||
|
value.mkdir(parents=True, exist_ok=True)
|
||||||
|
return value
|
||||||
594
src/huesoporro/static/css/mvp.css
Normal file
594
src/huesoporro/static/css/mvp.css
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
/* MVP.css v1.17 - https://github.com/andybrewer/mvp */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--active-brightness: 0.85;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--box-shadow: 2px 2px 10px;
|
||||||
|
--color-accent: #118bee15;
|
||||||
|
--color-bg: #fff;
|
||||||
|
--color-bg-secondary: #e9e9e9;
|
||||||
|
--color-link: #118bee;
|
||||||
|
--color-secondary: #920de9;
|
||||||
|
--color-secondary-accent: #920de90b;
|
||||||
|
--color-shadow: #f4f4f4;
|
||||||
|
--color-table: #118bee;
|
||||||
|
--color-text: #000;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-scrollbar: #cacae8;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
--hover-brightness: 1.2;
|
||||||
|
--justify-important: center;
|
||||||
|
--justify-normal: left;
|
||||||
|
--line-height: 1.5;
|
||||||
|
--width-card: 285px;
|
||||||
|
--width-card-medium: 460px;
|
||||||
|
--width-card-wide: 800px;
|
||||||
|
--width-content: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root[color-mode="user"] {
|
||||||
|
--color-accent: #0097fc4f;
|
||||||
|
--color-bg: #333;
|
||||||
|
--color-bg-secondary: #555;
|
||||||
|
--color-link: #0097fc;
|
||||||
|
--color-secondary: #e20de9;
|
||||||
|
--color-secondary-accent: #e20de94f;
|
||||||
|
--color-shadow: #bbbbbb20;
|
||||||
|
--color-table: #0097fc;
|
||||||
|
--color-text: #f7f7f7;
|
||||||
|
--color-text-secondary: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
article aside {
|
||||||
|
background: var(--color-secondary-accent);
|
||||||
|
border-left: 4px solid var(--color-secondary);
|
||||||
|
padding: 0.01rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
main {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--width-content);
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
margin: 4rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: var(--justify-important);
|
||||||
|
}
|
||||||
|
|
||||||
|
section img,
|
||||||
|
article img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
section pre {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
section aside {
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
width: var(--width-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
section aside:hover {
|
||||||
|
box-shadow: var(--box-shadow) var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
article header,
|
||||||
|
div header,
|
||||||
|
main header {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: var(--justify-important);
|
||||||
|
}
|
||||||
|
|
||||||
|
header a b,
|
||||||
|
header a em,
|
||||||
|
header a i,
|
||||||
|
header a strong {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav img {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section header {
|
||||||
|
padding-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
nav {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-weight: bold;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Dropdown */
|
||||||
|
nav ul li:hover ul {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li ul {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||||
|
display: none;
|
||||||
|
height: auto;
|
||||||
|
left: -2px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 1.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li ul::before {
|
||||||
|
/* fill gap above to make mousing over them easier */
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: -0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li ul li,
|
||||||
|
nav ul li ul li a {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav for Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li {
|
||||||
|
width: calc(100% - 1em);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li ul {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
display: block;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
code,
|
||||||
|
samp {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0.1rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
margin: 1.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: var(--line-height);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol li,
|
||||||
|
ul li {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
max-width: var(--width-card-wide);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code,
|
||||||
|
pre samp {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--width-card-wide);
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-size: xx-small;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.2rem;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--color-link);
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
filter: brightness(var(--hover-brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
filter: brightness(var(--active-brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
a b,
|
||||||
|
a em,
|
||||||
|
a i,
|
||||||
|
a strong,
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: inline-block;
|
||||||
|
font-size: medium;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: var(--line-height);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
input[type="submit"]:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
filter: brightness(var(--hover-brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active,
|
||||||
|
input[type="submit"]:active {
|
||||||
|
filter: brightness(var(--active-brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
a b,
|
||||||
|
a strong,
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
background-color: var(--color-link);
|
||||||
|
border: 2px solid var(--color-link);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a em,
|
||||||
|
a i {
|
||||||
|
border: 2px solid var(--color-link);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-link);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article aside a {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images */
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure figcaption {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
button:disabled,
|
||||||
|
input:disabled {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled]:hover,
|
||||||
|
input[type="submit"][disabled]:hover {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||||
|
display: block;
|
||||||
|
max-width: var(--width-card-wide);
|
||||||
|
min-width: var(--width-card);
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: var(--justify-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
form header {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
label,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
display: block;
|
||||||
|
font-size: inherit;
|
||||||
|
max-width: var(--width-card-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] + label,
|
||||||
|
input[type="radio"] + label {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: normal;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
|
width: calc(100% - 1.6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly],
|
||||||
|
textarea[readonly] {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popups */
|
||||||
|
dialog {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 85dvh;
|
||||||
|
margin: auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: none; /* Hide scrollbar for Firefox */
|
||||||
|
-ms-overflow-style: none; /* Hide scrollbar for IE and Edge */
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
animation: bottom-to-top 0.25s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
dialog {
|
||||||
|
max-width: 39rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bottom-to-top {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
border: 1px solid var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border-spacing: 0;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td,
|
||||||
|
table th,
|
||||||
|
table tr {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
text-align: var(--justify-important);
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead {
|
||||||
|
background-color: var(--color-table);
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-bg);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead tr:first-child th:first-child {
|
||||||
|
border-top-left-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead tr:first-child th:last-child {
|
||||||
|
border-top-right-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead th:first-child,
|
||||||
|
table tr td:first-child {
|
||||||
|
text-align: var(--justify-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr:nth-child(even) {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quotes */
|
||||||
|
blockquote {
|
||||||
|
display: block;
|
||||||
|
font-size: x-large;
|
||||||
|
line-height: var(--line-height);
|
||||||
|
margin: 1rem auto;
|
||||||
|
max-width: var(--width-card-medium);
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: var(--justify-important);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote footer {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: block;
|
||||||
|
font-size: small;
|
||||||
|
line-height: var(--line-height);
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-scrollbar) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-scrollbar);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
18
src/huesoporro/static/js/utils.js
Normal file
18
src/huesoporro/static/js/utils.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
function getWebsocketProtocol() {
|
||||||
|
// return "ws://" when localhost or "wss://"
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||||
|
return "ws://";
|
||||||
|
} else {
|
||||||
|
return "wss://";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogoutEvent() {
|
||||||
|
const logoutButton = document.getElementById("logoutButton");
|
||||||
|
logoutButton.addEventListener("click", () => {
|
||||||
|
document.cookie = "twitchLoginData=; expires=Thu, 01 Jan 1970 00:00:00 UTC";
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
16
src/huesoporro/templates/header.html
Normal file
16
src/huesoporro/templates/header.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/html">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/css/mvp.css">
|
||||||
|
|
||||||
|
<link rel="icon"
|
||||||
|
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="description" content="Huesoporro Twitch bot">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<script src="static/js/utils.js"></script>
|
||||||
|
|
||||||
|
<title>Huesoporro</title>
|
||||||
|
</head>
|
||||||
148
src/huesoporro/templates/index.html
Normal file
148
src/huesoporro/templates/index.html
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
|
||||||
|
{% include 'header.html' %}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li>Chatbot</li>
|
||||||
|
<li><a href="/tts">TTS</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a id="logoutButton" href="#" style="color: #aa0000;">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<h1>Huesoporro🦴🍃</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<label for="channelName">Enter channel name:</label>
|
||||||
|
<input type="text" id="channelName" placeholder="#huesoperro">
|
||||||
|
|
||||||
|
<button id="startButton" type="button">Start chatbot</button>
|
||||||
|
|
||||||
|
<button id="stopButton" type="button" disabled style="background-color: #aa0000; border-color: #aa0000">Stop
|
||||||
|
chatbot
|
||||||
|
</button>
|
||||||
|
<br/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details open="open">
|
||||||
|
<summary>Log</summary>
|
||||||
|
<div><samp id="log"></samp></div>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
class ChatbotManager {
|
||||||
|
constructor() {
|
||||||
|
this.url = getWebsocketProtocol() + window.location.host + "/ws";
|
||||||
|
this.logElement = document.getElementById('log');
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message) {
|
||||||
|
console.log(message);
|
||||||
|
this.logElement.innerHTML += message + '<br>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.socket = new WebSocket(this.url);
|
||||||
|
this.socket.withCredentials = true;
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
this.log("Connected to WebSocket " + this.url);
|
||||||
|
}
|
||||||
|
this.socket.onmessage = async (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.command === "chatbot_message") {
|
||||||
|
this.log(`[${message.data.username}]: ${message.data.message}`);
|
||||||
|
} else if (message.command === "chatbot_status") {
|
||||||
|
startButton.disabled = message.data.status === "ok";
|
||||||
|
stopButton.disabled = message.data.status === "ko";
|
||||||
|
this.log("Bot status is " + message.data.status)
|
||||||
|
} else if (message.command === "chatbot_start") {
|
||||||
|
this.log(message.data.log)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Error parsing message: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.socket.onerror = (error) => {
|
||||||
|
this.log(`WebSocket Error: ${error}`);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
this.socket.onclose = () => {
|
||||||
|
this.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async startBot() {
|
||||||
|
const channelNameInput = document.getElementById('channelName');
|
||||||
|
const channelName = channelNameInput ? channelNameInput.value : '';
|
||||||
|
|
||||||
|
const startCommand = {
|
||||||
|
command: "chatbot_start",
|
||||||
|
data: {
|
||||||
|
channel_name: channelName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify(startCommand));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopBot() {
|
||||||
|
const stopCommand = {
|
||||||
|
command: "chatbot_stop",
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify(stopCommand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatbotManager = new ChatbotManager();
|
||||||
|
chatbotManager.open()
|
||||||
|
|
||||||
|
const startButton = document.getElementById('startButton');
|
||||||
|
const stopButton = document.getElementById('stopButton');
|
||||||
|
if (startButton) {
|
||||||
|
startButton.addEventListener('click', () => {
|
||||||
|
chatbotManager.startBot()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Chatbot started successfully');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start chatbot', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopButton) {
|
||||||
|
stopButton.addEventListener('click', () => {
|
||||||
|
chatbotManager.stopBot()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Chatbot stopped successfully');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to stop chatbot', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addLogoutEvent()
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
131
src/huesoporro/templates/login.html
Normal file
131
src/huesoporro/templates/login.html
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/html">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/css/mvp.css">
|
||||||
|
|
||||||
|
<link rel="icon"
|
||||||
|
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="description" content="Huesoporro Twitch bot">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<title>Huesoporro login</title>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Huesoporro🦴🚬</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
|
||||||
|
<a href="{{ twitch_login_url }}" id="loginButton" type="button" style="color: #9c36b5; border-color: #9c36b5">Login
|
||||||
|
with
|
||||||
|
Twitch
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
class LoginManager {
|
||||||
|
constructor() {
|
||||||
|
this.loginData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to set a cookie
|
||||||
|
setCookie(name, value, days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
const expires = `expires=${date.toUTCString()}`;
|
||||||
|
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to get a cookie
|
||||||
|
getCookie(name) {
|
||||||
|
const cookieName = `${name}=`;
|
||||||
|
const decodedCookie = decodeURIComponent(document.cookie);
|
||||||
|
const cookieArray = decodedCookie.split(';');
|
||||||
|
|
||||||
|
for (let i = 0; i < cookieArray.length; i++) {
|
||||||
|
let cookie = cookieArray[i];
|
||||||
|
while (cookie.charAt(0) === ' ') {
|
||||||
|
cookie = cookie.substring(1);
|
||||||
|
}
|
||||||
|
if (cookie.indexOf(cookieName) === 0) {
|
||||||
|
return cookie.substring(cookieName.length, cookie.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readLoginData() {
|
||||||
|
// Try to get existing login data from cookies
|
||||||
|
const loginData = this.getCookie("twitchLoginData");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the stored login data if it exists
|
||||||
|
this.loginData = loginData ? JSON.parse(loginData) : null;
|
||||||
|
return this.loginData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading login data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
// Check if access_token is present in the URL hash
|
||||||
|
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||||
|
const accessToken = hashParams.get('access_token');
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
// Create login data object
|
||||||
|
const loginData = {
|
||||||
|
access_token: accessToken,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save login data to cookie (expires in 30 days)
|
||||||
|
this.setCookie("twitchLoginData", JSON.stringify(loginData), 30);
|
||||||
|
this.loginData = loginData;
|
||||||
|
|
||||||
|
// Hide the login button
|
||||||
|
const loginButton = document.getElementById("loginButton");
|
||||||
|
if (loginButton) {
|
||||||
|
loginButton.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the hash from the URL
|
||||||
|
history.replaceState(null, document.title, window.location.pathname);
|
||||||
|
|
||||||
|
// Redirect to home page or perform any other necessary actions
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving login data:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginManager = new LoginManager();
|
||||||
|
|
||||||
|
// Read existing login data
|
||||||
|
loginManager.readLoginData().then(loginData => {
|
||||||
|
const loginButton = document.getElementById("loginButton");
|
||||||
|
|
||||||
|
if (loginData) {
|
||||||
|
// If login data exists, redirect to home page
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
// If no login data, try to save settings (handle Twitch OAuth callback)
|
||||||
|
loginManager.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
184
src/huesoporro/templates/tts.html
Normal file
184
src/huesoporro/templates/tts.html
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
{% include 'header.html' %}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav id="navbar">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Chatbot</a></li>
|
||||||
|
<li>TTS</li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a id="logoutButton" href="#" style="color: #aa0000;">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
<h1>Huesoporro🦴🍃</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<form>
|
||||||
|
<label for="textInput">Enter text:</label>
|
||||||
|
<input type="text" id="textInput" placeholder="Hi huesoporro">
|
||||||
|
<button id="sendButton" type="button">Send text</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<audio id="audioPlayer" hidden="hidden" controls></audio>
|
||||||
|
<details open="open">
|
||||||
|
<summary>Log</summary>
|
||||||
|
<div><samp id="log"></samp></div>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
class AudioStreamer {
|
||||||
|
constructor() {
|
||||||
|
this.url = getWebsocketProtocol() + window.location.host + "/ws";
|
||||||
|
this.audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
this.logElement = document.getElementById('log');
|
||||||
|
this.audioBuffer = [];
|
||||||
|
this.expectedFileSize = 0;
|
||||||
|
this.receivedFileSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message) {
|
||||||
|
console.log(message);
|
||||||
|
this.logElement.innerHTML += message + '<br>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
// Establish WebSocket connection
|
||||||
|
this.audioBuffer = [];
|
||||||
|
this.expectedFileSize = 0;
|
||||||
|
this.receivedFileSize = 0;
|
||||||
|
this.log("Connecting to WebSocket: " + this.url);
|
||||||
|
this.websocket = new WebSocket(this.url);
|
||||||
|
this.websocket.onopen = () => {
|
||||||
|
this.log('WebSocket connection established');
|
||||||
|
};
|
||||||
|
this.websocket.onmessage = async (event) => {
|
||||||
|
try {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
if (event.data.startsWith('FILE_HEADER:')) {
|
||||||
|
this.expectedFileSize = parseInt(event.data.split(':')[1]);
|
||||||
|
this.log(`Expecting file of size: ${this.expectedFileSize} bytes`);
|
||||||
|
this.audioBuffer = [];
|
||||||
|
this.receivedFileSize = 0;
|
||||||
|
} else if (event.data === 'FILE_FOOTER') {
|
||||||
|
// Only play after file footer is received and all chunks are in
|
||||||
|
this.log(`Received complete file. Total size: ${this.receivedFileSize} bytes`);
|
||||||
|
if (this.receivedFileSize > 0) {
|
||||||
|
await this.playAudioBuffer();
|
||||||
|
} else {
|
||||||
|
this.log('No audio data received');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Accumulate chunks
|
||||||
|
const audioData = await event.data.arrayBuffer();
|
||||||
|
this.audioBuffer.push(audioData);
|
||||||
|
this.receivedFileSize += audioData.byteLength;
|
||||||
|
|
||||||
|
this.log(`Received chunk. Total received: ${this.receivedFileSize} / ${this.expectedFileSize}`);
|
||||||
|
|
||||||
|
if (this.receivedFileSize >= this.expectedFileSize) {
|
||||||
|
// Play audio when complete
|
||||||
|
await this.playAudioBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Error processing audio: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
this.log(`WebSocket error: ${error}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async combineBuffers(buffers) {
|
||||||
|
console.log(`Combining ${buffers.length} buffers`);
|
||||||
|
buffers.forEach((buffer, index) => {
|
||||||
|
console.log(`Buffer ${index} size: ${buffer.byteLength} bytes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate buffers
|
||||||
|
if (buffers.length === 0) {
|
||||||
|
console.error('No buffers to combine');
|
||||||
|
return new ArrayBuffer(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total length
|
||||||
|
const totalLength = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0);
|
||||||
|
console.log(`Total combined length: ${totalLength} bytes`);
|
||||||
|
|
||||||
|
// Create a new buffer and copy data
|
||||||
|
const combinedBuffer = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const buffer of buffers) {
|
||||||
|
combinedBuffer.set(new Uint8Array(buffer), offset);
|
||||||
|
offset += buffer.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedBuffer.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async playAudioBuffer() {
|
||||||
|
try {
|
||||||
|
const combinedBuffer = await this.combineBuffers(this.audioBuffer);
|
||||||
|
|
||||||
|
// Verify combined buffer
|
||||||
|
console.log(`Combined buffer size: ${combinedBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
|
const blob = new Blob([combinedBuffer], {type: 'audio/mpeg'});
|
||||||
|
|
||||||
|
console.log(`Blob size: ${blob.size} bytes`);
|
||||||
|
|
||||||
|
// Only proceed if blob has content
|
||||||
|
if (blob.size > 0) {
|
||||||
|
this.audioPlayer.src = URL.createObjectURL(blob);
|
||||||
|
await this.audioPlayer.play();
|
||||||
|
} else {
|
||||||
|
console.error('Blob is empty');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Audio buffer processing error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendText(text) {
|
||||||
|
// build a Websocket message and send it
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
"command": "tts_send",
|
||||||
|
"data": {
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
this.websocket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioStreamer = new AudioStreamer();
|
||||||
|
const sendButton = document.getElementById('sendButton');
|
||||||
|
const textInput = document.getElementById('textInput');
|
||||||
|
|
||||||
|
// Automatically connect on page load
|
||||||
|
audioStreamer.start();
|
||||||
|
|
||||||
|
sendButton.addEventListener('click', () => {
|
||||||
|
const text = textInput.value.trim();
|
||||||
|
if (text) {
|
||||||
|
audioStreamer.sendText(text);
|
||||||
|
textInput.value = ''; // Clear input
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addLogoutEvent()
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
131
src/huesoporro/tts.py
Normal file
131
src/huesoporro/tts.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
from hashlib import sha512
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
from litestar import WebSocket
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.huesoporro.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TTSManager:
|
||||||
|
TEXT_MAX_LENGTH: int = 400
|
||||||
|
|
||||||
|
def __init__(self, max_queue_size=10):
|
||||||
|
self.queue: deque = deque(maxlen=max_queue_size)
|
||||||
|
|
||||||
|
# Connected WebSocket clients
|
||||||
|
self.clients: list[WebSocket] = []
|
||||||
|
|
||||||
|
# Currently playing audio
|
||||||
|
self.current_audio = None
|
||||||
|
|
||||||
|
# Lock to prevent race conditions
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._tasks = []
|
||||||
|
self.s = Settings.get()
|
||||||
|
|
||||||
|
def generate_tts(self, text, language="pt", tld="com.br"):
|
||||||
|
# Generate unique filename
|
||||||
|
text = text[0 : self.TEXT_MAX_LENGTH]
|
||||||
|
filename = (
|
||||||
|
self.s.tts_cache_path / f"{sha512(text.lower().encode()).hexdigest()}.mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
if filename.exists():
|
||||||
|
logger.info(
|
||||||
|
f"TTS already exists for '{text[:50]}' at {filename}. Returning it"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"filename": filename.name,
|
||||||
|
"text": text,
|
||||||
|
"filepath": str(filename),
|
||||||
|
"language": language,
|
||||||
|
"tld": tld,
|
||||||
|
}
|
||||||
|
logger.info(f"Generating TTS for '{text[:50]}'")
|
||||||
|
|
||||||
|
# Generate TTS
|
||||||
|
tts = gTTS(text=text, lang=language, tld=tld)
|
||||||
|
tts.save(str(filename))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filename": filename.name,
|
||||||
|
"text": text,
|
||||||
|
"filepath": filename,
|
||||||
|
"language": language,
|
||||||
|
"tld": tld,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def add_to_queue(self, text, language="pt", tld="com.br"):
|
||||||
|
"""Add TTS request to queue and start processing if not already running"""
|
||||||
|
async with self._lock:
|
||||||
|
# Generate TTS file
|
||||||
|
audio_info = self.generate_tts(text, language, tld)
|
||||||
|
|
||||||
|
# Add to queue
|
||||||
|
self.queue.append(audio_info)
|
||||||
|
|
||||||
|
# If this is the only item, start processing
|
||||||
|
if len(self.queue) == 1:
|
||||||
|
self._tasks.append(asyncio.create_task(self.process_queue()))
|
||||||
|
|
||||||
|
return audio_info
|
||||||
|
|
||||||
|
async def process_queue(self):
|
||||||
|
"""Process queue and stream audio to connected clients"""
|
||||||
|
while True:
|
||||||
|
async with self._lock:
|
||||||
|
# Check if queue is empty
|
||||||
|
if not self.queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get next audio file
|
||||||
|
audio_info = self.queue[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read the entire audio file
|
||||||
|
audio_path = Path(audio_info["filepath"])
|
||||||
|
with audio_path.open("rb") as audio_file:
|
||||||
|
file_size = audio_path.stat().st_size
|
||||||
|
logger.info(
|
||||||
|
f"Streaming file: {audio_info['filename']}, Size: {file_size} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream audio to all connected clients
|
||||||
|
for client in self.clients:
|
||||||
|
try:
|
||||||
|
# Reset file pointer to beginning
|
||||||
|
audio_file.seek(0)
|
||||||
|
|
||||||
|
# Send file size first (as a header)
|
||||||
|
await client.send_text(f"FILE_HEADER:{file_size}")
|
||||||
|
|
||||||
|
# Stream file in chunks
|
||||||
|
chunk = audio_file.read(128) # Larger chunk size
|
||||||
|
chunk_count = 0
|
||||||
|
while chunk:
|
||||||
|
logger.info(f"Streamed {chunk_count} chunks")
|
||||||
|
chunk_count += 1
|
||||||
|
await client.send_bytes(chunk)
|
||||||
|
chunk = audio_file.read(128)
|
||||||
|
|
||||||
|
# Send file footer
|
||||||
|
await client.send_text("FILE_FOOTER")
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.error(
|
||||||
|
f"Error streaming to client {client.client}. Removing it."
|
||||||
|
)
|
||||||
|
if client in self.clients:
|
||||||
|
self.clients.remove(client)
|
||||||
|
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error(f"Error processing audio file: {e}")
|
||||||
|
|
||||||
|
# Remove the processed item from the queue
|
||||||
|
async with self._lock:
|
||||||
|
if self.queue and self.queue[0] == audio_info:
|
||||||
|
self.queue.popleft()
|
||||||
16
src/huesoporro/value_objects.py
Normal file
16
src/huesoporro/value_objects.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketCommands(StrEnum):
|
||||||
|
TTS_SEND = "tts_send"
|
||||||
|
CHATBOT_START = "chatbot_start"
|
||||||
|
CHATBOT_STOP = "chatbot_stop"
|
||||||
|
CHATBOT_STATUS = "chatbot_status"
|
||||||
|
CHATBOT_UPDATE = "chatbot_update"
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketMessage(BaseModel):
|
||||||
|
command: WebsocketCommands
|
||||||
|
data: dict
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from traceback import print_exc
|
|
||||||
|
|
||||||
from kivy.clock import Clock
|
|
||||||
from kivy.metrics import dp
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.textinput import TextInput
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from src.markovbot_gui.libs.markov_chain_bot import MarkovChain
|
|
||||||
from src.markovbot_gui.libs.settings import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class QueueHandler:
|
|
||||||
def __init__(self, queue):
|
|
||||||
self.queue = queue
|
|
||||||
|
|
||||||
def write(self, message):
|
|
||||||
self.queue.put(message)
|
|
||||||
|
|
||||||
def flush(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BotRunner(BoxLayout):
|
|
||||||
def __init__(self, settings_path: Path, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.settings_path = settings_path
|
|
||||||
self.orientation = "vertical"
|
|
||||||
self.spacing = dp(10)
|
|
||||||
self.padding = dp(20)
|
|
||||||
self.bot_thread = None
|
|
||||||
self.log_queue: queue.Queue = queue.Queue()
|
|
||||||
self.settings = Settings.read(self.settings_path)
|
|
||||||
|
|
||||||
self.queue_handler = QueueHandler(self.log_queue)
|
|
||||||
logger.remove()
|
|
||||||
logger.add(
|
|
||||||
self.queue_handler,
|
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}",
|
|
||||||
level=self.settings.log_level,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.log_display = TextInput(
|
|
||||||
multiline=True,
|
|
||||||
readonly=True,
|
|
||||||
size_hint=(1, 1),
|
|
||||||
background_color=[0.1, 0.1, 0.1, 1], # Dark background
|
|
||||||
foreground_color=[0.9, 0.9, 0.9, 1], # Light text
|
|
||||||
)
|
|
||||||
self.add_widget(self.log_display)
|
|
||||||
|
|
||||||
# Create button layout
|
|
||||||
button_layout = BoxLayout(
|
|
||||||
orientation="horizontal",
|
|
||||||
size_hint=(1, None),
|
|
||||||
height=dp(40),
|
|
||||||
spacing=dp(10),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create start button
|
|
||||||
self.start_button = Button(
|
|
||||||
text="Start Bot",
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(100), dp(40)),
|
|
||||||
)
|
|
||||||
self.start_button.bind(on_release=self.start_bot)
|
|
||||||
button_layout.add_widget(self.start_button)
|
|
||||||
|
|
||||||
# Create stop button
|
|
||||||
self.stop_button = Button(
|
|
||||||
text="Stop Bot",
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(100), dp(40)),
|
|
||||||
disabled=True,
|
|
||||||
)
|
|
||||||
self.stop_button.bind(on_release=self.stop_bot)
|
|
||||||
button_layout.add_widget(self.stop_button)
|
|
||||||
|
|
||||||
# Create clear log button
|
|
||||||
self.clear_button = Button(
|
|
||||||
text="Clear Log",
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(100), dp(40)),
|
|
||||||
)
|
|
||||||
self.clear_button.bind(on_release=self.clear_log)
|
|
||||||
button_layout.add_widget(self.clear_button)
|
|
||||||
|
|
||||||
self.add_widget(button_layout)
|
|
||||||
|
|
||||||
Clock.schedule_interval(self.update_log, 0.1)
|
|
||||||
|
|
||||||
def start_bot(self, instance=None):
|
|
||||||
try:
|
|
||||||
# Create and start bot thread
|
|
||||||
self.bot_thread = threading.Thread(target=self.run_bot_thread, daemon=True)
|
|
||||||
self.bot_thread.start()
|
|
||||||
|
|
||||||
self.start_button.disabled = True
|
|
||||||
self.stop_button.disabled = False
|
|
||||||
|
|
||||||
logger.info("Starting bot...")
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.error(f"Failed to start bot: {e}")
|
|
||||||
|
|
||||||
def run_bot_thread(self):
|
|
||||||
try:
|
|
||||||
self.bot = MarkovChain(self.settings)
|
|
||||||
self.bot.run_bot()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
logger.exception("Bot error")
|
|
||||||
finally:
|
|
||||||
Clock.schedule_once(lambda dt: self.reset_button_states(), 0)
|
|
||||||
|
|
||||||
def stop_bot(self, _=None):
|
|
||||||
self.bot.stop_bot()
|
|
||||||
|
|
||||||
# Wait for thread to finish
|
|
||||||
if self.bot_thread and self.bot_thread.is_alive():
|
|
||||||
self.bot_thread.join(timeout=3.0)
|
|
||||||
|
|
||||||
logger.info("Bot stopped")
|
|
||||||
self.reset_button_states()
|
|
||||||
|
|
||||||
def reset_button_states(self):
|
|
||||||
self.start_button.disabled = False
|
|
||||||
self.stop_button.disabled = True
|
|
||||||
|
|
||||||
def clear_log(self, instance=None):
|
|
||||||
self.log_display.text = ""
|
|
||||||
logger.info("Log cleared")
|
|
||||||
|
|
||||||
def update_log(self, dt):
|
|
||||||
try:
|
|
||||||
while not self.log_queue.empty():
|
|
||||||
message = self.log_queue.get_nowait()
|
|
||||||
if message.strip(): # Only add non-empty messages
|
|
||||||
self.log_display.text += message
|
|
||||||
|
|
||||||
# Keep only the last 1000 lines to prevent memory issues
|
|
||||||
lines = self.log_display.text.split("\n")
|
|
||||||
if len(lines) > 1000: # noqa: PLR2004
|
|
||||||
self.log_display.text = "\n".join(lines[-1000:]) + "\n"
|
|
||||||
|
|
||||||
# Auto-scroll to bottom
|
|
||||||
self.log_display.cursor = (0, len(self.log_display.text))
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
print_exc()
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from kivy.clock import Clock
|
|
||||||
from kivy.metrics import dp
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.label import Label
|
|
||||||
from kivy.uix.popup import Popup
|
|
||||||
from kivy.uix.textinput import TextInput
|
|
||||||
|
|
||||||
from src.markovbot_gui.libs.settings import Settings
|
|
||||||
from src.markovbot_gui.libs.timer import logger
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigWindow(BoxLayout):
|
|
||||||
def __init__(self, config_path: Path, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.config_path = config_path
|
|
||||||
self.orientation = "vertical"
|
|
||||||
self.spacing = dp(10)
|
|
||||||
self.padding = dp(20)
|
|
||||||
|
|
||||||
# Load existing configuration
|
|
||||||
default_config = {
|
|
||||||
"Host": "irc.chat.twitch.tv",
|
|
||||||
"Port": 6667,
|
|
||||||
"Channel": "#<channel>",
|
|
||||||
"Nickname": "<name>",
|
|
||||||
"Authentication": "oauth:<auth>",
|
|
||||||
"DeniedUsers": ["StreamElements", "Nightbot", "Moobot", "Marbiebot"],
|
|
||||||
"Cooldown": 20,
|
|
||||||
"KeyLength": 2,
|
|
||||||
"MaxSentenceWordAmount": 25,
|
|
||||||
"MinSentenceWordAmount": -1,
|
|
||||||
"HelpMessageTimer": 60 * 60 * 5, # 18000 seconds, 5 hours
|
|
||||||
"AutomaticGenerationTimer": -1,
|
|
||||||
"WhisperCooldown": True,
|
|
||||||
"EnableGenerateCommand": True,
|
|
||||||
"SentenceSeparator": " - ",
|
|
||||||
"AllowGenerateParams": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
if config_path.exists():
|
|
||||||
self.s = Settings.read(config_path)
|
|
||||||
else:
|
|
||||||
self.s = Settings(**default_config) # type: ignore[arg-type]
|
|
||||||
self.s.write(config_path)
|
|
||||||
|
|
||||||
# Create widgets
|
|
||||||
# Channel input
|
|
||||||
channel_layout = BoxLayout(
|
|
||||||
orientation="horizontal",
|
|
||||||
size_hint_y=None,
|
|
||||||
height=dp(40),
|
|
||||||
)
|
|
||||||
channel_label = Label(text="Channel:", size_hint_x=0.3)
|
|
||||||
self.channel_input = TextInput(
|
|
||||||
multiline=False,
|
|
||||||
size_hint_x=0.7,
|
|
||||||
text=self.s.channel,
|
|
||||||
)
|
|
||||||
channel_layout.add_widget(channel_label)
|
|
||||||
channel_layout.add_widget(self.channel_input)
|
|
||||||
|
|
||||||
# Nickname input
|
|
||||||
nickname_layout = BoxLayout(
|
|
||||||
orientation="horizontal",
|
|
||||||
size_hint_y=None,
|
|
||||||
height=dp(40),
|
|
||||||
)
|
|
||||||
nickname_label = Label(text="Nickname:", size_hint_x=0.3)
|
|
||||||
self.nickname_input = TextInput(
|
|
||||||
multiline=False,
|
|
||||||
size_hint_x=0.7,
|
|
||||||
text=self.s.nickname,
|
|
||||||
)
|
|
||||||
nickname_layout.add_widget(nickname_label)
|
|
||||||
nickname_layout.add_widget(self.nickname_input)
|
|
||||||
|
|
||||||
# Authentication input
|
|
||||||
auth_layout = BoxLayout(
|
|
||||||
orientation="horizontal",
|
|
||||||
size_hint_y=None,
|
|
||||||
height=dp(40),
|
|
||||||
)
|
|
||||||
auth_label = Label(text="Auth:", size_hint_x=0.3)
|
|
||||||
self.auth_input = TextInput(
|
|
||||||
multiline=False,
|
|
||||||
size_hint_x=0.7,
|
|
||||||
password=True,
|
|
||||||
text=self.s.authentication,
|
|
||||||
)
|
|
||||||
auth_layout.add_widget(auth_label)
|
|
||||||
auth_layout.add_widget(self.auth_input)
|
|
||||||
|
|
||||||
automatic_generation_label = Label(text="Automatic generation (seconds): ")
|
|
||||||
self.automatic_generation_input = TextInput(
|
|
||||||
multiline=False,
|
|
||||||
size_hint_x=0.7,
|
|
||||||
text=str(self.s.automatic_generation_timer),
|
|
||||||
)
|
|
||||||
automatic_generation_layout = BoxLayout(
|
|
||||||
orientation="horizontal",
|
|
||||||
size_hint_y=None,
|
|
||||||
height=dp(40),
|
|
||||||
)
|
|
||||||
automatic_generation_layout.add_widget(automatic_generation_label)
|
|
||||||
automatic_generation_layout.add_widget(self.automatic_generation_input)
|
|
||||||
|
|
||||||
# Save button
|
|
||||||
save_button = Button(
|
|
||||||
text="Save",
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(100), dp(40)),
|
|
||||||
pos_hint={"center_x": 0.5},
|
|
||||||
)
|
|
||||||
save_button.bind(on_release=self.save_config)
|
|
||||||
|
|
||||||
# Add all widgets to the layout
|
|
||||||
self.add_widget(channel_layout)
|
|
||||||
self.add_widget(nickname_layout)
|
|
||||||
self.add_widget(auth_layout)
|
|
||||||
self.add_widget(automatic_generation_layout)
|
|
||||||
self.add_widget(save_button)
|
|
||||||
|
|
||||||
def save_config(self, instance):
|
|
||||||
try:
|
|
||||||
self.s.channel = self.channel_input.text.strip()
|
|
||||||
self.s.nickname = self.nickname_input.text.strip()
|
|
||||||
self.s.authentication = self.auth_input.text.strip()
|
|
||||||
self.s.automatic_generation_timer = int(
|
|
||||||
self.automatic_generation_input.text
|
|
||||||
)
|
|
||||||
if 0 < self.s.automatic_generation_timer < 29: # noqa: PLR2004
|
|
||||||
raise ValueError(
|
|
||||||
"Value for 'Automatic generation' must be at least 30 seconds, " # noqa: EM101
|
|
||||||
"or a negative number for no automatic generations."
|
|
||||||
)
|
|
||||||
self.s.write(self.config_path)
|
|
||||||
|
|
||||||
# Show success message
|
|
||||||
success_popup = Popup(
|
|
||||||
title="Success",
|
|
||||||
content=Label(text="Configuration saved successfully"),
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(250), dp(100)),
|
|
||||||
)
|
|
||||||
success_popup.open()
|
|
||||||
|
|
||||||
Clock.schedule_once(success_popup.dismiss, 1)
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
self.show_error_message(f"Failed to save configuration:\n{e!s}")
|
|
||||||
error_popup = Popup(
|
|
||||||
title="Error",
|
|
||||||
content=Label(text=f"Failed to save configuration:\n{e!s}"),
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(400), dp(150)),
|
|
||||||
)
|
|
||||||
error_popup.open()
|
|
||||||
logger.exception("Failed to save configuration")
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class LogHandler(logging.Handler):
|
|
||||||
def __init__(self, log_queue):
|
|
||||||
super().__init__()
|
|
||||||
self.log_queue = log_queue
|
|
||||||
|
|
||||||
def emit(self, record):
|
|
||||||
self.log_queue.put(self.format(record))
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import platformdirs
|
|
||||||
from kivy.app import App
|
|
||||||
from kivy.metrics import dp
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.popup import Popup
|
|
||||||
from kivy.uix.widget import Widget
|
|
||||||
|
|
||||||
from src.markovbot_gui.bot_runner import BotRunner
|
|
||||||
from src.markovbot_gui.config_window import ConfigWindow
|
|
||||||
|
|
||||||
|
|
||||||
class BotApp(App):
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.config_path = (
|
|
||||||
platformdirs.user_config_path("markovbot_gui") / "settings.json"
|
|
||||||
)
|
|
||||||
self.data_path = platformdirs.user_data_path("markovbot_gui")
|
|
||||||
|
|
||||||
def run_bot(self, instance):
|
|
||||||
bot_runner = BotRunner(settings_path=self.config_path)
|
|
||||||
popup = Popup(
|
|
||||||
title=f"Bot runner, database available at {self.data_path}",
|
|
||||||
content=bot_runner,
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(600), dp(600)),
|
|
||||||
auto_dismiss=False,
|
|
||||||
)
|
|
||||||
popup.open()
|
|
||||||
|
|
||||||
def run_config(self, instance):
|
|
||||||
config_window = ConfigWindow(config_path=self.config_path)
|
|
||||||
popup = Popup(
|
|
||||||
title=f"Bot configuration, available at {self.config_path}",
|
|
||||||
content=config_window,
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(600), dp(400)),
|
|
||||||
auto_dismiss=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add close button
|
|
||||||
close_button = Button(
|
|
||||||
text="Close",
|
|
||||||
size_hint=(None, None),
|
|
||||||
size=(dp(100), dp(40)),
|
|
||||||
pos_hint={"center_x": 0.5},
|
|
||||||
)
|
|
||||||
close_button.bind(on_release=popup.dismiss)
|
|
||||||
config_window.add_widget(close_button)
|
|
||||||
|
|
||||||
popup.open()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
widget = Widget()
|
|
||||||
|
|
||||||
layout = BoxLayout(size_hint=(1, None), height=50)
|
|
||||||
|
|
||||||
run_button = Button(text="Run bot")
|
|
||||||
run_button.bind(on_release=self.run_bot)
|
|
||||||
layout.add_widget(run_button)
|
|
||||||
|
|
||||||
config_button = Button(text="Open config")
|
|
||||||
config_button.bind(on_release=self.run_config)
|
|
||||||
layout.add_widget(config_button)
|
|
||||||
|
|
||||||
root = BoxLayout(orientation="vertical")
|
|
||||||
root.add_widget(widget)
|
|
||||||
root.add_widget(layout)
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
BotApp().run()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue