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/
|
||||
.pdm.toml
|
||||
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:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
|
|
@ -16,21 +18,40 @@ repos:
|
|||
- id: mixed-line-ending
|
||||
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
|
||||
hooks:
|
||||
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: uv run mypy
|
||||
entry: uv run mypy --check-untyped-defs
|
||||
language: system
|
||||
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:
|
||||
uvx pre-commit run --all-files --color always
|
||||
|
||||
|
||||
.PHONY: tests
|
||||
tests:
|
||||
uv run pytest --cov=halig -vv tests --report-log reportlog.json
|
||||
uv run coverage html
|
||||
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]
|
||||
name = "markovbot-gui"
|
||||
version = "0.1.2"
|
||||
description = "Markov Chain Bot GUI"
|
||||
name = "huesoporro"
|
||||
version = "0.2.0"
|
||||
description = "Misc Twitch bots"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "tomaarsen" },
|
||||
{ name = "185504a9", email = "catalin@roboces.dev" }
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"kivy[base]>=2.3.0",
|
||||
"nltk>=3.9.1",
|
||||
"pillow>=10.4.0",
|
||||
"platformdirs>=4.3.6",
|
||||
"pydantic>=2.9.2",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"pyinstaller>=6.11.0",
|
||||
"twitchwebsocket>=1.2.1",
|
||||
"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]
|
||||
dev-dependencies = [
|
||||
"mypy>=1.13.0",
|
||||
"pyright>=1.1.387",
|
||||
"ruff>=0.7.0",
|
||||
]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
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.tokenize",
|
||||
"nltk.tokenize.treebank",
|
||||
"nltk.tokenize.destructive",
|
||||
"TwitchWebsocket",
|
||||
"tokenizer"
|
||||
"tokenizer",
|
||||
"gtts"
|
||||
]
|
||||
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):
|
||||
self.user_data_path = platformdirs.user_data_path(
|
||||
"markovbot_gui",
|
||||
"huesoporro",
|
||||
ensure_exists=True,
|
||||
)
|
||||
self.db_path = (
|
||||
|
|
@ -191,6 +191,7 @@ class Database:
|
|||
fetch=True,
|
||||
):
|
||||
logger.info("Creating backup before updating Database...")
|
||||
|
||||
# Connect to both the new and backup, backup, and close both
|
||||
|
||||
def progress(status, remaining, total):
|
||||
|
|
@ -356,7 +357,7 @@ class Database:
|
|||
|
||||
from nltk import ngrams
|
||||
|
||||
from src.markovbot_gui.libs.tokenizer import tokenize
|
||||
from src.huesoporro.libs.tokenizer import tokenize
|
||||
|
||||
channel = channel.replace("#", "").lower()
|
||||
copyfile(
|
||||
|
|
@ -6,10 +6,10 @@ from loguru import logger
|
|||
from nltk.tokenize import sent_tokenize
|
||||
from TwitchWebsocket import Message, TwitchWebsocket
|
||||
|
||||
from src.markovbot_gui.libs.db import Database
|
||||
from src.markovbot_gui.libs.settings import Settings
|
||||
from src.markovbot_gui.libs.timer import LoopingTimer
|
||||
from src.markovbot_gui.libs.tokenizer import detokenize, tokenize
|
||||
from src.huesoporro.libs.db import Database
|
||||
from src.huesoporro.libs.settings import Settings
|
||||
from src.huesoporro.libs.timer import LoopingTimer
|
||||
from src.huesoporro.libs.tokenizer import detokenize, tokenize
|
||||
|
||||
|
||||
class Commands(StrEnum):
|
||||
|
|
@ -68,11 +68,12 @@ class MarkovChain:
|
|||
)
|
||||
|
||||
def run_bot(self):
|
||||
self.ws.start_bot()
|
||||
self.ws.start_nonblocking()
|
||||
|
||||
def stop_bot(self):
|
||||
self.ws.leave_channel(self.s.channel_name)
|
||||
self.ws.stop()
|
||||
logger.info("Stopped bot")
|
||||
|
||||
def _command_help(self) -> None:
|
||||
"""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
|
||||
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:
|
||||
logger.debug(f"User {message.user} can't send messages")
|
||||
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