From b2185f4174af6dc2085c92a23aaf76ab6cbba63e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?c=C4=83t=C4=83lin?=
Date: Wed, 26 Feb 2025 11:53:18 +0100
Subject: [PATCH 01/16] feat: remove !h and make the bot have an in-memory dict
of greeted users instead of using the backoff service
---
Dockerfile | 3 +-
Makefile | 2 +-
charts/huesoporro/Chart.yaml | 4 +-
charts/huesoporro/values.yaml | 2 +-
devenv.nix | 1 -
pyproject.toml | 20 ++-
src/{ => apps}/__init__.py | 0
src/{huesoporro/api => apps/cli}/__init__.py | 0
.../api/routes => apps/cli/typer}/__init__.py | 0
src/apps/cli/typer/main.py | 24 +++
src/apps/httpapi/__init__.py | 0
src/apps/httpapi/litestar/__init__.py | 0
.../httpapi/litestar}/dependencies.py | 25 ++--
.../api => apps/httpapi/litestar}/errors.py | 0
.../api => apps/httpapi/litestar}/main.py | 20 +--
src/apps/httpapi/litestar/routes/__init__.py | 0
.../httpapi/litestar}/routes/api.py | 35 ++---
.../httpapi/litestar}/routes/auth.py | 4 +-
src/huesoporro/actions/authenticate.py | 8 +-
src/huesoporro/actions/get_random_quote.py | 4 +-
src/huesoporro/actions/get_user_by_jwt.py | 8 +-
src/huesoporro/actions/import_from_vod.py | 34 +++++
src/huesoporro/actions/refresh.py | 8 +-
src/huesoporro/actions/store_quote.py | 6 +-
src/huesoporro/bot.py | 81 ++++++-----
src/huesoporro/infra/authenticator.py | 4 +-
src/huesoporro/infra/db.py | 23 +--
src/huesoporro/infra/gtts.py | 2 +-
src/huesoporro/infra/repos.py | 4 +-
src/huesoporro/libs/db.py | 20 ++-
src/huesoporro/main.py | 2 +-
src/huesoporro/models.py | 2 +-
src/huesoporro/settings.py | 18 ++-
src/huesoporro/svc/clean_cc_svc.py | 57 ++++++++
.../svc/download_closed_captions.py | 44 ++++++
src/huesoporro/svc/generate.py | 4 +-
src/huesoporro/svc/get_chatbot_settings.py | 4 +-
src/huesoporro/svc/get_random_quote.py | 4 +-
src/huesoporro/svc/get_sentences_svc.py | 11 --
src/huesoporro/svc/hello.py | 41 ++++--
src/huesoporro/svc/is_mod.py | 4 +-
src/huesoporro/svc/quote_storer_svc.py | 4 +-
src/huesoporro/svc/store.py | 6 +-
src/huesoporro/svc/store_settings.py | 4 +-
src/huesoporro/templates/index.html | 1 -
.../templates/le_funny_dropdown.html | 5 +-
src/huesoporro/templates/sentences.html | 137 ------------------
src/huesoporro/tts.py | 2 +-
tests/conftest.py | 10 +-
tests/test_repos.py | 4 +-
tests/test_svc.py | 4 +-
uv.lock | 45 +++++-
52 files changed, 403 insertions(+), 352 deletions(-)
rename src/{ => apps}/__init__.py (100%)
rename src/{huesoporro/api => apps/cli}/__init__.py (100%)
rename src/{huesoporro/api/routes => apps/cli/typer}/__init__.py (100%)
create mode 100644 src/apps/cli/typer/main.py
create mode 100644 src/apps/httpapi/__init__.py
create mode 100644 src/apps/httpapi/litestar/__init__.py
rename src/{huesoporro/api => apps/httpapi/litestar}/dependencies.py (66%)
rename src/{huesoporro/api => apps/httpapi/litestar}/errors.py (100%)
rename src/{huesoporro/api => apps/httpapi/litestar}/main.py (84%)
create mode 100644 src/apps/httpapi/litestar/routes/__init__.py
rename src/{huesoporro/api => apps/httpapi/litestar}/routes/api.py (75%)
rename src/{huesoporro/api => apps/httpapi/litestar}/routes/auth.py (89%)
create mode 100644 src/huesoporro/actions/import_from_vod.py
create mode 100644 src/huesoporro/svc/clean_cc_svc.py
create mode 100644 src/huesoporro/svc/download_closed_captions.py
delete mode 100644 src/huesoporro/svc/get_sentences_svc.py
delete mode 100644 src/huesoporro/templates/sentences.html
diff --git a/Dockerfile b/Dockerfile
index 226e72a..f58efb1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,9 +29,10 @@ WORKDIR "$APP_PATH"
COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./
+COPY --chown=$USERNAME src/ src/
+
RUN uv sync
-COPY --chown=$USERNAME src/ src/
COPY --chown=$USERNAME migrations/ migrations/
diff --git a/Makefile b/Makefile
index 1443b85..61f0995 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ tests:
uv run coverage xml
serve:
- uv run python -m src.huesoporro.main
+ uv run python -m src.apps.httpapi.litestar.main
build:
docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET)
diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml
index 782ccdb..716c06d 100644
--- a/charts/huesoporro/Chart.yaml
+++ b/charts/huesoporro/Chart.yaml
@@ -15,10 +15,10 @@ 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.9
+version: 0.3.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.9"
+appVersion: "0.3.0"
diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml
index 7f92219..54c4d4d 100644
--- a/charts/huesoporro/values.yaml
+++ b/charts/huesoporro/values.yaml
@@ -11,7 +11,7 @@ image:
# This sets the pull policy for images.
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
- tag: "0.2.9"
+ tag: "0.3.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: []
diff --git a/devenv.nix b/devenv.nix
index 2342729..06edec4 100644
--- a/devenv.nix
+++ b/devenv.nix
@@ -10,7 +10,6 @@
languages.python.version = "3.12.8";
enterShell = ''
- uv sync
'';
dotenv.enable = true;
diff --git a/pyproject.toml b/pyproject.toml
index 53cc9f2..c30ecf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "huesoporro"
-version = "0.2.9"
-description = "Misc Twitch bots"
+version = "0.3.0"
+description = "Misc Twitch bot"
readme = "README.md"
authors = [
{ name = "185504a9", email = "catalin@roboces.dev" }
@@ -25,6 +25,13 @@ dependencies = [
"discord-py>=2.4.0",
]
+[project.scripts]
+huesoporro = "apps.cli.typer.main:app"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
[tool.uv]
dev-dependencies = [
"mypy>=1.13.0",
@@ -46,7 +53,8 @@ module = [
"caribou.migrate",
"twitchio",
"twitchio.ext",
- "gtts"
+ "gtts",
+ "yt_dlp"
]
ignore_missing_imports = true
@@ -60,3 +68,9 @@ extend-ignore = ["S101", "ISC002", "COM812", "ISC001", "EM101", "EM102"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+
+[dependency-groups]
+cli = [
+ "typer>=0.15.1",
+ "yt-dlp>=2025.1.26",
+]
diff --git a/src/__init__.py b/src/apps/__init__.py
similarity index 100%
rename from src/__init__.py
rename to src/apps/__init__.py
diff --git a/src/huesoporro/api/__init__.py b/src/apps/cli/__init__.py
similarity index 100%
rename from src/huesoporro/api/__init__.py
rename to src/apps/cli/__init__.py
diff --git a/src/huesoporro/api/routes/__init__.py b/src/apps/cli/typer/__init__.py
similarity index 100%
rename from src/huesoporro/api/routes/__init__.py
rename to src/apps/cli/typer/__init__.py
diff --git a/src/apps/cli/typer/main.py b/src/apps/cli/typer/main.py
new file mode 100644
index 0000000..5f14c84
--- /dev/null
+++ b/src/apps/cli/typer/main.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+from loguru import logger
+from typer import Typer
+
+from huesoporro.actions.import_from_vod import ImportFromVODAction
+from huesoporro.settings import Settings
+from huesoporro.svc.clean_cc_svc import CleanCCSvc
+from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
+
+app = Typer()
+
+
+@app.command()
+def import_vod_cc(channel_name: str, youtube_url: str, db_path: Path | None = None):
+ logger.info(f"Importing VOD closed captions for {channel_name} from {youtube_url}")
+ s = Settings.get(db_filepath=db_path)
+ import_from_vod_action = ImportFromVODAction(
+ download_closed_captions_svc=DownloadClosedCaptionsSvc(),
+ clean_cc_svc=CleanCCSvc(),
+ s=s,
+ )
+ for cc_filepath in import_from_vod_action.run(channel_name, youtube_url):
+ logger.info(f"Closed captions imported from {cc_filepath}")
diff --git a/src/apps/httpapi/__init__.py b/src/apps/httpapi/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/httpapi/litestar/__init__.py b/src/apps/httpapi/litestar/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/huesoporro/api/dependencies.py b/src/apps/httpapi/litestar/dependencies.py
similarity index 66%
rename from src/huesoporro/api/dependencies.py
rename to src/apps/httpapi/litestar/dependencies.py
index d1d9812..bf80ece 100644
--- a/src/huesoporro/api/dependencies.py
+++ b/src/apps/httpapi/litestar/dependencies.py
@@ -1,16 +1,17 @@
from litestar import Request
from litestar.exceptions import HTTPException
-from src.huesoporro.actions.authenticate import AuthenticateAction
-from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction
-from src.huesoporro.infra.authenticator import TwitchAuthenticator
-from src.huesoporro.infra.db import Database
-from src.huesoporro.infra.repos import UserRepo
-from src.huesoporro.models import User
-from src.huesoporro.settings import Settings
-from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
-from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc
-from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
+from huesoporro.actions.authenticate import AuthenticateAction
+from huesoporro.actions.get_user_by_jwt import GetUserByJWTAction
+from huesoporro.infra.authenticator import TwitchAuthenticator
+from huesoporro.infra.db import Database
+from huesoporro.infra.repos import UserRepo
+from huesoporro.libs.db import MarkovDatabase
+from huesoporro.models import User
+from huesoporro.settings import Settings
+from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
+from huesoporro.svc.store import SentenceStorerSvc
+from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
def get_settings() -> Settings:
@@ -53,8 +54,8 @@ async def store_chatbot_settings_svc(db: Database):
return ChatbotSettingsStorerSvc(db=db)
-async def get_sentences_svc(db: Database):
- return SentencesGetterSvc(db=db)
+async def get_sentences_storer_svc(db: MarkovDatabase):
+ return SentenceStorerSvc(db=db)
async def get_user_repo(s: Settings):
diff --git a/src/huesoporro/api/errors.py b/src/apps/httpapi/litestar/errors.py
similarity index 100%
rename from src/huesoporro/api/errors.py
rename to src/apps/httpapi/litestar/errors.py
diff --git a/src/huesoporro/api/main.py b/src/apps/httpapi/litestar/main.py
similarity index 84%
rename from src/huesoporro/api/main.py
rename to src/apps/httpapi/litestar/main.py
index 8df704e..dbd0d2b 100644
--- a/src/huesoporro/api/main.py
+++ b/src/apps/httpapi/litestar/main.py
@@ -6,37 +6,35 @@ from litestar.exceptions import HTTPException
from litestar.static_files import StaticFilesConfig
from litestar.template import TemplateConfig
-from src.huesoporro.api.dependencies import (
+from apps.httpapi.litestar.dependencies import (
authenticate,
get_authenticate_action,
get_authenticator,
get_chatbot_settings_svc,
get_db,
get_get_user_by_jwt_action,
- get_sentences_svc,
+ get_sentences_storer_svc,
get_settings,
get_user_repo,
store_chatbot_settings_svc,
)
-from src.huesoporro.api.errors import (
+from apps.httpapi.litestar.errors import (
after_exception_handler,
http_exception_handler,
httpx_status_error_handler,
)
-from src.huesoporro.api.routes.api import (
+from apps.httpapi.litestar.routes.api import (
get_bot_settings,
get_bot_status,
get_index,
- get_sentences,
get_tts_overlay,
get_tts_permalink,
manage_bot,
save_bot_settings,
- save_new_sentence,
)
-from src.huesoporro.api.routes.auth import get_code, login
-from src.huesoporro.bot import BotsManager
-from src.huesoporro.settings import Settings
+from apps.httpapi.litestar.routes.auth import get_code, login
+from huesoporro.bot import BotsManager
+from huesoporro.settings import Settings
@get("/healthz")
@@ -57,8 +55,6 @@ def create_app():
get_bot_status,
save_bot_settings,
get_bot_settings,
- get_sentences,
- save_new_sentence,
],
static_files_config=(
StaticFilesConfig(
@@ -87,7 +83,7 @@ def create_app():
"bm": Provide(BotsManager, use_cache=True),
"gbs": Provide(get_chatbot_settings_svc),
"sbs": Provide(store_chatbot_settings_svc),
- "sgs": Provide(get_sentences_svc),
+ "sss": Provide(get_sentences_storer_svc),
"authenticator": Provide(get_authenticator),
"authenticate_action": Provide(get_authenticate_action),
"user_repo": Provide(get_user_repo),
diff --git a/src/apps/httpapi/litestar/routes/__init__.py b/src/apps/httpapi/litestar/routes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/huesoporro/api/routes/api.py b/src/apps/httpapi/litestar/routes/api.py
similarity index 75%
rename from src/huesoporro/api/routes/api.py
rename to src/apps/httpapi/litestar/routes/api.py
index 607789f..9fd317b 100644
--- a/src/huesoporro/api/routes/api.py
+++ b/src/apps/httpapi/litestar/routes/api.py
@@ -1,14 +1,14 @@
from typing import Literal
-from litestar import MediaType, Response, get, post, put
+from litestar import MediaType, Response, get, put
+from litestar.datastructures import UploadFile
from litestar.response import Template
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
-from src.huesoporro.bot import BotsManager
-from src.huesoporro.models import ChatbotSettings, User
-from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
-from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc
-from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
+from huesoporro.bot import BotsManager
+from huesoporro.models import ChatbotSettings, User
+from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
+from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
class ManageBotDTO(BaseModel):
@@ -16,6 +16,13 @@ class ManageBotDTO(BaseModel):
channel_name: str | None = None
+class ImportTextFileDTO(BaseModel):
+ file: UploadFile
+ channel_name: str
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+
@get(
"/tts",
media_type=MediaType.HTML,
@@ -97,17 +104,3 @@ async def save_bot_settings(
) -> dict:
await sbs.run(user=user, bot_settings=data)
return {"status": "ok"}
-
-
-@get("/sentences")
-async def get_sentences(user: User, sgs: SentencesGetterSvc) -> Template:
- sentences = await sgs.run(user=user)
- return Template(
- template_name="sentences.html",
- context={"sentences": [sentence.model_dump() for sentence in sentences]},
- )
-
-
-@post("/api/v1/sentences")
-async def save_new_sentence(user: User, data: dict) -> dict:
- return {"id": 54, "sentence": data["sentence"]}
diff --git a/src/huesoporro/api/routes/auth.py b/src/apps/httpapi/litestar/routes/auth.py
similarity index 89%
rename from src/huesoporro/api/routes/auth.py
rename to src/apps/httpapi/litestar/routes/auth.py
index 7d930b2..cd01bdc 100644
--- a/src/huesoporro/api/routes/auth.py
+++ b/src/apps/httpapi/litestar/routes/auth.py
@@ -4,8 +4,8 @@ from litestar import MediaType, get
from litestar.datastructures.cookie import Cookie
from litestar.response import Redirect, Template
-from src.huesoporro.actions.authenticate import AuthenticateAction
-from src.huesoporro.settings import Settings
+from huesoporro.actions.authenticate import AuthenticateAction
+from huesoporro.settings import Settings
@get(path="/o/code")
diff --git a/src/huesoporro/actions/authenticate.py b/src/huesoporro/actions/authenticate.py
index 9bf7b6a..7fcb466 100644
--- a/src/huesoporro/actions/authenticate.py
+++ b/src/huesoporro/actions/authenticate.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from src.huesoporro.infra.authenticator import TwitchAuthenticator
-from src.huesoporro.infra.repos import UserRepo
-from src.huesoporro.models import User
-from src.huesoporro.settings import Settings
+from huesoporro.infra.authenticator import TwitchAuthenticator
+from huesoporro.infra.repos import UserRepo
+from huesoporro.models import User
+from huesoporro.settings import Settings
class AuthenticateAction(BaseModel):
diff --git a/src/huesoporro/actions/get_random_quote.py b/src/huesoporro/actions/get_random_quote.py
index 47c7b5b..99df32b 100644
--- a/src/huesoporro/actions/get_random_quote.py
+++ b/src/huesoporro/actions/get_random_quote.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.models import Quote
-from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
+from huesoporro.models import Quote
+from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
class GetRandomQuoteAction(BaseModel):
diff --git a/src/huesoporro/actions/get_user_by_jwt.py b/src/huesoporro/actions/get_user_by_jwt.py
index 4993b4c..21e362e 100644
--- a/src/huesoporro/actions/get_user_by_jwt.py
+++ b/src/huesoporro/actions/get_user_by_jwt.py
@@ -1,10 +1,10 @@
from loguru import logger
from pydantic import BaseModel
-from src.huesoporro.infra.authenticator import TwitchAuthenticator
-from src.huesoporro.infra.repos import UserRepo
-from src.huesoporro.models import User
-from src.huesoporro.settings import Settings
+from huesoporro.infra.authenticator import TwitchAuthenticator
+from huesoporro.infra.repos import UserRepo
+from huesoporro.models import User
+from huesoporro.settings import Settings
class GetUserByJWTAction(BaseModel):
diff --git a/src/huesoporro/actions/import_from_vod.py b/src/huesoporro/actions/import_from_vod.py
new file mode 100644
index 0000000..be62ea5
--- /dev/null
+++ b/src/huesoporro/actions/import_from_vod.py
@@ -0,0 +1,34 @@
+from collections.abc import Generator
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+from huesoporro.libs.db import MarkovDatabase
+from huesoporro.settings import Settings
+from huesoporro.svc.clean_cc_svc import CleanCCSvc
+from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
+from huesoporro.svc.store import SentenceStorerSvc
+
+
+class ImportFromVODAction(BaseModel):
+ download_closed_captions_svc: DownloadClosedCaptionsSvc
+
+ clean_cc_svc: CleanCCSvc
+ s: Settings = Field(default_factory=Settings.get)
+
+ ignore_lines: set[str] = {
+ "WEBVTT",
+ "Kind: captions",
+ "Language: en",
+ "Language: es",
+ }
+
+ def run(self, channel_name: str, youtube_url: str) -> Generator[Path, None, None]:
+ for cc_filepath in self.download_closed_captions_svc.run(youtube_url):
+ storer_svc = SentenceStorerSvc(
+ db=MarkovDatabase(channel=channel_name, settings=self.s)
+ )
+ for line in self.clean_cc_svc.run(cc_filepath):
+ if line and line not in self.ignore_lines:
+ storer_svc.store_sentence(line.strip())
+ yield cc_filepath
diff --git a/src/huesoporro/actions/refresh.py b/src/huesoporro/actions/refresh.py
index f28cc7c..7dafa77 100644
--- a/src/huesoporro/actions/refresh.py
+++ b/src/huesoporro/actions/refresh.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from src.huesoporro.infra.authenticator import TwitchAuthenticator
-from src.huesoporro.infra.repos import UserRepo
-from src.huesoporro.models import User
-from src.huesoporro.settings import Settings
+from huesoporro.infra.authenticator import TwitchAuthenticator
+from huesoporro.infra.repos import UserRepo
+from huesoporro.models import User
+from huesoporro.settings import Settings
class RefreshAction(BaseModel):
diff --git a/src/huesoporro/actions/store_quote.py b/src/huesoporro/actions/store_quote.py
index 87b5a84..0bee177 100644
--- a/src/huesoporro/actions/store_quote.py
+++ b/src/huesoporro/actions/store_quote.py
@@ -2,9 +2,9 @@ import datetime
from pydantic import BaseModel
-from src.huesoporro.models import Quote, User
-from src.huesoporro.svc.is_mod import IsModSvc
-from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc
+from huesoporro.models import Quote, User
+from huesoporro.svc.is_mod import IsModSvc
+from huesoporro.svc.quote_storer_svc import QuoteStorerSvc
class StoreQuoteAction(BaseModel):
diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py
index 14c39db..ae07057 100644
--- a/src/huesoporro/bot.py
+++ b/src/huesoporro/bot.py
@@ -2,25 +2,26 @@ import asyncio
import random
from collections.abc import Callable
from enum import StrEnum
+from typing import ClassVar
from loguru import logger
from twitchio import Channel
from twitchio.ext import commands, routines
-from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction
-from src.huesoporro.actions.store_quote import StoreQuoteAction
-from src.huesoporro.api.dependencies import get_settings
-from src.huesoporro.infra.db import Database
-from src.huesoporro.infra.repos import QuoteRepo
-from src.huesoporro.libs.db import Database as MarkovDB
-from src.huesoporro.models import ChatbotSettings, User
-from src.huesoporro.svc.backoff_service import BackoffService
-from src.huesoporro.svc.generate import SentenceGeneratorSvc
-from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
-from src.huesoporro.svc.hello import HelloGeneratorSvc
-from src.huesoporro.svc.is_mod import IsModSvc
-from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc
-from src.huesoporro.svc.store import SentenceStorerSvc
+from huesoporro.actions.get_random_quote import GetRandomQuoteAction
+from huesoporro.actions.store_quote import StoreQuoteAction
+from huesoporro.infra.db import Database
+from huesoporro.infra.repos import QuoteRepo
+from huesoporro.libs.db import MarkovDatabase
+from huesoporro.models import ChatbotSettings, User
+from huesoporro.settings import Settings
+from huesoporro.svc.backoff_service import BackoffService
+from huesoporro.svc.generate import SentenceGeneratorSvc
+from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
+from huesoporro.svc.hello import get_hello_generator_svc
+from huesoporro.svc.is_mod import IsModSvc
+from huesoporro.svc.quote_storer_svc import QuoteStorerSvc
+from huesoporro.svc.store import SentenceStorerSvc
class Bot(commands.Bot):
@@ -30,10 +31,10 @@ class Bot(commands.Bot):
)
self.channel = channel
self.user = user
- self.generate_svc = SentenceGeneratorSvc(db=MarkovDB(channel=channel))
- self.hello_svc = HelloGeneratorSvc()
+ self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=channel))
+ self.hello_svc = get_hello_generator_svc()
db = Database()
- self.quote_repo = QuoteRepo(s=get_settings())
+ self.quote_repo = QuoteRepo(s=Settings.get())
self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo)
self.get_random_quote_action = GetRandomQuoteAction(
quote_getter_svc=self.get_random_quote_svc
@@ -54,11 +55,6 @@ class Bot(commands.Bot):
logger.info(f"Logged in as {self.nick}")
logger.info(f"User id is {self.user_id}")
- @commands.command(aliases=["h"])
- async def hello(self, ctx: commands.Context, username: str | None = None):
- username = username or ctx.author.name
- await ctx.send(self.hello_svc.run(username))
-
@commands.command(aliases=["g"])
async def generate(self, ctx: commands.Context, *, words: str | None = None):
sentence = await self.generate_svc.run(words)
@@ -98,8 +94,9 @@ class Bot(commands.Bot):
quote = await self.get_random_quote_action.run(channel_name=self.channel)
if quote:
channel = self.get_channel_conn()
- logger.info(f"Sending random quote {quote.quote}")
- await channel.send(quote.quote)
+ if channel:
+ logger.info(f"Sending random quote {quote.quote}")
+ await channel.send(quote.quote)
async def send_generation(self):
sentence = await self.generate_svc.run()
@@ -123,6 +120,23 @@ class Bot(commands.Bot):
self.generation_routine.cancel()
+class HelloMessagesCog(commands.Cog):
+ hello_patterns: ClassVar[list[str]] = ["hola", "HOLA", "hiii", "ayo"]
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.hello_svc = get_hello_generator_svc()
+
+ @commands.Cog.event()
+ async def event_message(self, message):
+ if not message.author:
+ return
+ if message.content in self.hello_patterns:
+ hello = self.hello_svc.run(message.author.name)
+ if hello:
+ await message.channel.send(hello)
+
+
class MessageType(StrEnum):
COMMAND = "COMMAND"
HELLO = "HELLO"
@@ -136,7 +150,6 @@ class MessageHandler:
"""Handles different types of messages with their corresponding responses"""
def __init__(self, channel_send_func: Callable):
- self.hello_patterns = ["hola", "HOLA", "hiii", "ayo"]
self.laugh_patterns = [
"om",
"KEK",
@@ -154,8 +167,6 @@ class MessageHandler:
"""Determines the type of message based on its content"""
if content.startswith("!"):
return MessageType.COMMAND
- if content in self.hello_patterns:
- return MessageType.HELLO
if content == "Yes":
return MessageType.YES
if content.startswith("WHAT"):
@@ -164,10 +175,6 @@ class MessageHandler:
return MessageType.LAUGH
return MessageType.OTHER
- async def handle_hello(self, author_name: str, hello_svc) -> str:
- """Handles hello messages"""
- return hello_svc.run(author_name)
-
async def handle_laugh(self) -> str:
"""Handles laugh messages"""
return random.choice(self.laugh_patterns) # noqa: S311
@@ -176,20 +183,17 @@ class MessageHandler:
class SaveMessagesCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
- self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel))
- self.hello_svc = HelloGeneratorSvc()
+ self.store_svc = SentenceStorerSvc(db=MarkovDatabase(channel=bot.channel))
+ self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=bot.channel))
self.backoff_svc = BackoffService()
self.message_handler = MessageHandler(self._send_message)
- # Register a separate send function for each message type
self.send_functions = {
- MessageType.HELLO: self._create_typed_send("hello"),
MessageType.YES: self._create_typed_send("yes"),
MessageType.WHAT: self._create_typed_send("what"),
MessageType.LAUGH: self._create_typed_send("laugh"),
}
- # Register each send function with its own backoff
for func in self.send_functions.values():
self.backoff_svc.add_callable(func, backoff_seconds=10)
@@ -229,10 +233,6 @@ class SaveMessagesCog(commands.Cog):
match msg_type:
case MessageType.COMMAND:
return
- case MessageType.HELLO:
- response = await self.message_handler.handle_hello(
- message.author.name, self.hello_svc
- )
case MessageType.YES:
response = "Indeed"
case MessageType.WHAT:
@@ -258,6 +258,7 @@ class BotsManager:
logger.info(f"Adding bot for {user.user}")
bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings)
bot.add_cog(SaveMessagesCog(bot))
+ bot.add_cog(HelloMessagesCog(bot))
self.bots[user.user] = bot
async def run_user_bot(self, user: User):
diff --git a/src/huesoporro/infra/authenticator.py b/src/huesoporro/infra/authenticator.py
index 0405c8a..6f99839 100644
--- a/src/huesoporro/infra/authenticator.py
+++ b/src/huesoporro/infra/authenticator.py
@@ -2,8 +2,8 @@ import httpx
from litestar.exceptions import HTTPException
from pydantic import BaseModel, ConfigDict, Field
-from src.huesoporro.models import TwitchAuth
-from src.huesoporro.settings import Settings
+from huesoporro.models import TwitchAuth
+from huesoporro.settings import Settings
class TwitchAuthenticator(BaseModel):
diff --git a/src/huesoporro/infra/db.py b/src/huesoporro/infra/db.py
index 898b768..44405f7 100644
--- a/src/huesoporro/infra/db.py
+++ b/src/huesoporro/infra/db.py
@@ -5,8 +5,8 @@ import aiosqlite
from loguru import logger
from pydantic import BaseModel, Field
-from src.huesoporro.models import ChatbotSettings, Sentence, User
-from src.huesoporro.settings import Settings
+from huesoporro.models import ChatbotSettings, User
+from huesoporro.settings import Settings
class Database(BaseModel):
@@ -78,22 +78,3 @@ class Database(BaseModel):
if not result:
return None
return ChatbotSettings(**dict(result))
-
- async def save_sentence(self, sentence: str, auto_commit=True):
- async with self.get_client(auto_commit=auto_commit) as db:
- await db.execute(
- "INSERT INTO sentences (sentence) VALUES (?)",
- (sentence,),
- )
- await db.commit()
-
- async def get_sentences(self, user: User) -> list[Sentence]:
- async with self.get_client() as db:
- db.row_factory = aiosqlite.Row
- async with db.execute(
- "SELECT * FROM sentences WHERE user_id = ?", (user.user,)
- ) as cursor:
- result = await cursor.fetchall()
- if not result:
- return []
- return [Sentence(user=user, **dict(value)) for value in result]
diff --git a/src/huesoporro/infra/gtts.py b/src/huesoporro/infra/gtts.py
index 10814ec..98a3c74 100644
--- a/src/huesoporro/infra/gtts.py
+++ b/src/huesoporro/infra/gtts.py
@@ -6,7 +6,7 @@ from gtts import gTTS
from loguru import logger
from pydantic import BaseModel
-from src.huesoporro.settings import Settings
+from huesoporro.settings import Settings
class GTTS(BaseModel):
diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py
index 3256ffa..7e88554 100644
--- a/src/huesoporro/infra/repos.py
+++ b/src/huesoporro/infra/repos.py
@@ -6,8 +6,8 @@ from typing import Generic, TypeVar
import aiosqlite
from pydantic import BaseModel, Field
-from src.huesoporro.models import Quote, User
-from src.huesoporro.settings import Settings
+from huesoporro.models import Quote, User
+from huesoporro.settings import Settings
T = TypeVar("T", bound=BaseModel)
diff --git a/src/huesoporro/libs/db.py b/src/huesoporro/libs/db.py
index ef523ca..615c706 100644
--- a/src/huesoporro/libs/db.py
+++ b/src/huesoporro/libs/db.py
@@ -4,11 +4,12 @@ import sqlite3
import string
from typing import Any
-import platformdirs
from loguru import logger
+from huesoporro.settings import Settings
-class Database:
+
+class MarkovDatabase:
"""
The database created is called `MarkovChain_{channel}.db`,
and populated with 27 + 27^2 = 756 tables. Firstly, 27 tables with the structure of
@@ -86,14 +87,11 @@ class Database:
to both get results from "hello" and "hello,".
"""
- def __init__(self, channel: str):
- self.user_data_path = platformdirs.user_data_path(
- "huesoporro",
- ensure_exists=True,
- )
- self.db_path = (
- self.user_data_path / f"MarkovChain_{channel.replace('#', '').lower()}.db"
- )
+ def __init__(self, channel: str, settings: Settings | None = None):
+ settings = settings or Settings.get()
+
+ self.db_path = settings.default_data_path / f"MarkovChain_{channel}.db"
+ self.user_data_path = self.db_path.parent
self._execute_queue: list = []
if self.db_path.is_file():
@@ -357,7 +355,7 @@ class Database:
from nltk import ngrams
- from src.huesoporro.libs.tokenizer import tokenize
+ from huesoporro.libs.tokenizer import tokenize
channel = channel.replace("#", "").lower()
copyfile(
diff --git a/src/huesoporro/main.py b/src/huesoporro/main.py
index 318c67d..fde026e 100644
--- a/src/huesoporro/main.py
+++ b/src/huesoporro/main.py
@@ -1,6 +1,6 @@
import uvicorn
-from src.huesoporro.settings import Settings
+from huesoporro.settings import Settings
if __name__ == "__main__":
settings = Settings.get()
diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py
index 86f977b..a832646 100644
--- a/src/huesoporro/models.py
+++ b/src/huesoporro/models.py
@@ -4,7 +4,7 @@ from typing import Literal
import jwt
from pydantic import BaseModel, Field, field_validator
-from src.huesoporro.settings import Settings
+from huesoporro.settings import Settings
class TwitchAuth(BaseModel):
diff --git a/src/huesoporro/settings.py b/src/huesoporro/settings.py
index 358abc0..a9dd968 100644
--- a/src/huesoporro/settings.py
+++ b/src/huesoporro/settings.py
@@ -1,6 +1,7 @@
from functools import lru_cache
from pathlib import Path
+import platformdirs
from pydantic import Field, HttpUrl, SecretStr, field_validator
from pydantic_settings import BaseSettings
@@ -8,18 +9,19 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
port: int = 8000
host: str = "0.0.0.0" # noqa: S104
+
+ default_data_path: Path = platformdirs.user_data_path(
+ "huesoporro",
+ ensure_exists=True,
+ )
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"
- )
+ tts_cache_path: Path = default_data_path / "tts_files"
+ db_filepath: Path = default_data_path / "huesoporro.db"
twitch_client_id: str
twitch_client_secret: SecretStr
jwt_secret: SecretStr
@@ -31,8 +33,8 @@ class Settings(BaseSettings):
@staticmethod
@lru_cache(maxsize=1)
- def get():
- return Settings() # type: ignore[call-arg] # pydantic-setting magic
+ def get(**data):
+ return Settings(**data) # type: ignore[call-arg] # pydantic-setting magic
@field_validator("allowed_users")
@classmethod
diff --git a/src/huesoporro/svc/clean_cc_svc.py b/src/huesoporro/svc/clean_cc_svc.py
new file mode 100644
index 0000000..8680d71
--- /dev/null
+++ b/src/huesoporro/svc/clean_cc_svc.py
@@ -0,0 +1,57 @@
+import re
+from collections.abc import Generator
+from pathlib import Path
+
+from pydantic import BaseModel
+
+
+class CleanCCSvc(BaseModel):
+ @classmethod
+ def clean_vtt_line(cls, line):
+ """
+ Clean a single line of VTT text by removing timestamps and HTML tags.
+
+ Args:
+ line (str): Single line from VTT file
+
+ Returns:
+ str: Cleaned line or None if line should be skipped
+ """
+ # Skip empty lines
+ if not line.strip():
+ return None
+ # Skip WEBVTT header
+ if line.strip().startswith("WEBVTT"):
+ return None
+
+ # Skip timestamp lines (e.g., "00:00:00.000 --> 00:00:02.000")
+ if re.match(
+ r"\d{2}:\d{2}:\d{2}\.\d{3}\s+-{2}>\s+\d{2}:\d{2}:\d{2}\.\d{3}", line
+ ):
+ return None
+
+ # Skip numeric identifiers
+ if line.strip().isdigit():
+ return None
+
+ # Remove HTML-style tags
+ line = re.sub(r"<[^>]+>", "", line)
+
+ # Remove multiple spaces
+ line = " ".join(line.split())
+
+ return line.strip()
+
+ @classmethod
+ def process_vtt_file(cls, file_path: Path):
+ seen_lines = set()
+
+ with file_path.open("r", encoding="utf-8") as f:
+ for line in f:
+ cleaned = cls.clean_vtt_line(line)
+ if line not in seen_lines:
+ seen_lines.add(line)
+ yield cleaned
+
+ def run(self, cc_file_path: Path) -> Generator[str, None, None]:
+ return self.process_vtt_file(cc_file_path)
diff --git a/src/huesoporro/svc/download_closed_captions.py b/src/huesoporro/svc/download_closed_captions.py
new file mode 100644
index 0000000..c8c2305
--- /dev/null
+++ b/src/huesoporro/svc/download_closed_captions.py
@@ -0,0 +1,44 @@
+import tempfile
+from collections.abc import Generator
+from pathlib import Path
+
+import yt_dlp
+from loguru import logger
+from pydantic import BaseModel
+
+
+class DownloadClosedCaptionsSvc(BaseModel):
+ @staticmethod
+ def run(youtube_url: str, sub_lang: str = "es") -> Generator[Path, None, None]:
+ """Download closed captions from a yt video and save it to a temp file
+
+ Args:
+ youtube_url: URL of the YouTube video
+ sub_lang: Language code for the subtitles (default: "es" for Spanish)
+
+ Returns:
+ Path: Path to the downloaded subtitles file
+
+ Raises:
+ ValueError: If subtitles are not available in the requested language
+ """
+ temp_dir = Path(tempfile.mkdtemp())
+ logger.info(f"Downloading subtitles for {youtube_url} to {temp_dir}")
+ ydl_opts = {
+ "skip_download": True,
+ "writesubtitles": True,
+ "writeautomaticsub": True,
+ "subtitleslangs": [sub_lang],
+ "subtitlesformat": "vtt",
+ "cookiesfrombrowser": ("firefox",),
+ "paths": {"home": str(temp_dir)},
+ "quiet": True,
+ }
+
+ try:
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ ydl.download([youtube_url])
+ return temp_dir.glob(f"*.{sub_lang}.vtt")
+
+ except yt_dlp.utils.DownloadError as exc:
+ raise ValueError(f"Failed to download subtitles: {exc!s}") from exc
diff --git a/src/huesoporro/svc/generate.py b/src/huesoporro/svc/generate.py
index 5bef7fe..d4a1766 100644
--- a/src/huesoporro/svc/generate.py
+++ b/src/huesoporro/svc/generate.py
@@ -3,8 +3,8 @@ import string
from loguru import logger
from pydantic import BaseModel, ConfigDict
-from src.huesoporro.libs.db import Database as MarkovDB
-from src.huesoporro.libs.tokenizer import detokenize, tokenize
+from huesoporro.libs.db import MarkovDatabase as MarkovDB
+from huesoporro.libs.tokenizer import detokenize, tokenize
class SentenceGeneratorSvc(BaseModel):
diff --git a/src/huesoporro/svc/get_chatbot_settings.py b/src/huesoporro/svc/get_chatbot_settings.py
index a1bfc2f..a5b2e19 100644
--- a/src/huesoporro/svc/get_chatbot_settings.py
+++ b/src/huesoporro/svc/get_chatbot_settings.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.infra.db import Database
-from src.huesoporro.models import ChatbotSettings, User
+from huesoporro.infra.db import Database
+from huesoporro.models import ChatbotSettings, User
class ChatbotSettingsGetterSvc(BaseModel):
diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py
index 055b633..d82cffd 100644
--- a/src/huesoporro/svc/get_random_quote.py
+++ b/src/huesoporro/svc/get_random_quote.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.infra.repos import QuoteRepo
-from src.huesoporro.models import Quote
+from huesoporro.infra.repos import QuoteRepo
+from huesoporro.models import Quote
class RandomQuoteGetterSvc(BaseModel):
diff --git a/src/huesoporro/svc/get_sentences_svc.py b/src/huesoporro/svc/get_sentences_svc.py
deleted file mode 100644
index 1cb0a1c..0000000
--- a/src/huesoporro/svc/get_sentences_svc.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from pydantic import BaseModel
-
-from src.huesoporro.infra.db import Database
-from src.huesoporro.models import Sentence, User
-
-
-class SentencesGetterSvc(BaseModel):
- db: Database
-
- async def run(self, user: User) -> list[Sentence]:
- return await self.db.get_sentences(user=user)
diff --git a/src/huesoporro/svc/hello.py b/src/huesoporro/svc/hello.py
index 5be2180..d0b49cc 100644
--- a/src/huesoporro/svc/hello.py
+++ b/src/huesoporro/svc/hello.py
@@ -1,20 +1,35 @@
import random
+from functools import lru_cache
+from loguru import logger
from pydantic import BaseModel, Field
class HelloGeneratorSvc(BaseModel):
- hellos: list[str] = Field(
- default_factory=lambda: [
- "Hola",
- "Ayo",
- "Hi",
- "Bon día",
- "Hola mi tremendo elemento",
- "HOLA",
- "hiii",
- ]
- )
+ hellos: list[str] = [
+ "Hola",
+ "Ayo",
+ "Hi",
+ "Bon día",
+ "Hola mi tremendo elemento",
+ "HOLA",
+ "hiii",
+ ]
- def run(self, username: str):
- return f"{random.choice(self.hellos)} @{username}" # noqa: S311
+ greeted_users: dict[str, str] = Field(default_factory=dict)
+
+ def run(self, username: str) -> str | None:
+ if username in self.greeted_users:
+ logger.info(f"User {username} already greeted")
+ return None
+
+ greeting = f"{random.choice(self.hellos)} @{username}" # noqa: S311
+
+ self.greeted_users[username] = greeting
+
+ return greeting
+
+
+@lru_cache(maxsize=1)
+def get_hello_generator_svc() -> HelloGeneratorSvc:
+ return HelloGeneratorSvc()
diff --git a/src/huesoporro/svc/is_mod.py b/src/huesoporro/svc/is_mod.py
index d15e988..49f2969 100644
--- a/src/huesoporro/svc/is_mod.py
+++ b/src/huesoporro/svc/is_mod.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.infra.db import Database
-from src.huesoporro.models import User
+from huesoporro.infra.db import Database
+from huesoporro.models import User
class IsModSvc(BaseModel):
diff --git a/src/huesoporro/svc/quote_storer_svc.py b/src/huesoporro/svc/quote_storer_svc.py
index bc71320..0806e88 100644
--- a/src/huesoporro/svc/quote_storer_svc.py
+++ b/src/huesoporro/svc/quote_storer_svc.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.infra.repos import QuoteRepo
-from src.huesoporro.models import Quote
+from huesoporro.infra.repos import QuoteRepo
+from huesoporro.models import Quote
class QuoteStorerSvc(BaseModel):
diff --git a/src/huesoporro/svc/store.py b/src/huesoporro/svc/store.py
index 48e3115..2343f1d 100644
--- a/src/huesoporro/svc/store.py
+++ b/src/huesoporro/svc/store.py
@@ -2,12 +2,12 @@ from loguru import logger
from nltk.tokenize import sent_tokenize
from pydantic import BaseModel, ConfigDict
-from src.huesoporro.libs.db import Database as MarkovDB
-from src.huesoporro.libs.tokenizer import tokenize
+from huesoporro.libs.db import MarkovDatabase
+from huesoporro.libs.tokenizer import tokenize
class SentenceStorerSvc(BaseModel):
- db: MarkovDB
+ db: MarkovDatabase
key_length: int = 2
end_tag: str = ""
diff --git a/src/huesoporro/svc/store_settings.py b/src/huesoporro/svc/store_settings.py
index 96ef827..59ca6ab 100644
--- a/src/huesoporro/svc/store_settings.py
+++ b/src/huesoporro/svc/store_settings.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel
-from src.huesoporro.infra.db import Database
-from src.huesoporro.models import ChatbotSettings, User
+from huesoporro.infra.db import Database
+from huesoporro.models import ChatbotSettings, User
class ChatbotSettingsStorerSvc(BaseModel):
diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html
index 082c17e..a2debe5 100644
--- a/src/huesoporro/templates/index.html
+++ b/src/huesoporro/templates/index.html
@@ -185,7 +185,6 @@
const chatbotManager = new ChatbotManager();
chatbotManager.setEvents();
-
});
-
-
-
-
-
-
-
-
-
-
- | Sentence |
- Last modified |
- Action |
-
-
-
- {% for sentence in sentences %}
-
- |
- {{ sentence.last_updated_at }} |
-
-
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-