feat: remove kivy frontend, add litestar

This commit is contained in:
cătălin 2024-12-12 12:22:34 +01:00
commit 6b873348c7
No known key found for this signature in database
48 changed files with 3092 additions and 800 deletions

3
.envrc Normal file
View file

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
use devenv

19
.gitignore vendored
View file

@ -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

View file

@ -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
View 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"]

View file

@ -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)

View file

@ -0,0 +1 @@
# huesoporro

View 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/

View 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"

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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

View 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
View 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
View 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
View 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

View file

@ -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,
)

View file

@ -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
View 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)

View file

@ -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,10 +191,11 @@ 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):
logging.debug(f"Copied {total-remaining} of {total} pages...") logging.debug(f"Copied {total - remaining} of {total} pages...")
conn = sqlite3.connect(f"MarkovChain_{channel.replace('#', '').lower()}.db") conn = sqlite3.connect(f"MarkovChain_{channel.replace('#', '').lower()}.db")
back_conn = sqlite3.connect( back_conn = sqlite3.connect(
@ -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(

View file

@ -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
View 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)

View 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

View 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;
}

View 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 = "/";
});
}

View 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>

View 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>

View 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>

View 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
View 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()

View 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

View file

@ -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()

View file

@ -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")

View file

@ -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))

View file

@ -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()

1089
uv.lock generated

File diff suppressed because it is too large Load diff