feat: remove !h and make the bot have an in-memory dict of greeted users instead of using the backoff service

This commit is contained in:
cătălin 2025-02-26 11:53:18 +01:00
commit b2185f4174
No known key found for this signature in database
52 changed files with 404 additions and 353 deletions

View file

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

View file

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

View file

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

View file

@ -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: []

View file

@ -10,7 +10,6 @@
languages.python.version = "3.12.8";
enterShell = ''
uv sync
'';
dotenv.enable = true;

View file

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

View file

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

View file

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,6 +94,7 @@ class Bot(commands.Bot):
quote = await self.get_random_quote_action.run(channel_name=self.channel)
if quote:
channel = self.get_channel_conn()
if channel:
logger.info(f"Sending random quote {quote.quote}")
await channel.send(quote.quote)
@ -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):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import uvicorn
from src.huesoporro.settings import Settings
from huesoporro.settings import Settings
if __name__ == "__main__":
settings = Settings.get()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,12 @@
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: [
hellos: list[str] = [
"Hola",
"Ayo",
"Hi",
@ -14,7 +15,21 @@ class HelloGeneratorSvc(BaseModel):
"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()

View file

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

View file

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

View file

@ -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 = "<END>"

View file

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

View file

@ -185,7 +185,6 @@
const chatbotManager = new ChatbotManager();
chatbotManager.setEvents();
});
</script>
</body>

View file

@ -2,9 +2,8 @@
<details class="dropdown">
<summary>Le Funny</summary>
<ul dir="rtl">
<li><a href="/sentences" >Sentences</a></li>
<li><a href="#" disabled>Quotes</a></li>
<li><a href="#" disabled>Copypastas</a></li>
<li><a href="/quotes" disabled>Quotes</a></li>
<li><a href="/copypastas" disabled>Copypastas</a></li>
</ul>
</details>
</li>

View file

@ -1,137 +0,0 @@
{% include 'header.html' %}
<body>
<header>
<nav class="container">
<ul>
<li><a href="/">Chatbot</a></li>
<li><a href="/tts">TTS</a></li>
{% include 'le_funny_dropdown.html' %}
</ul>
{% include 'logout.html' %}
</nav>
<script>
function timestampToAwareDatetime(timestamp) {
const awareDatetime = new Date(timestamp);
awareDatetime.setMinutes(awareDatetime.getMinutes() - awareDatetime.getTimezoneOffset());
return awareDatetime;
}
function prettifyTimestamp(timestamp) {
const awareDatetime = timestampToAwareDatetime(timestamp);
return awareDatetime.toLocaleString();
}
function saveNewSentence(shortHash) {
/*
Save a newly-added sentence by POST'ing against /api/v1/sentences.
- if response is not 200, return nothing (validation logic to be added later
- if response is 200, retrieve save-new-${shortHash} and delete-new-${shortHas} and change their ids
to save-${newSentenceIdRetrievedFromResponse} and delete-${newSentenceIdRetrievedFromResponse}
assume a body like: {"id": 1, "sentence": "New sentence"}
*/
const sentenceTextInput = document.getElementById(`newSentence${shortHash}`);
const sentence = sentenceTextInput.value;
// if sentence is empty, return
if (!sentence) {
return;
}
const sentenceData = {
"sentence": sentence
}
fetch(`/api/v1/sentences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sentenceData)
}).then(response => {
response.json().then(data => {
console.log(data);
const saveButton = document.getElementById(`saveNew${shortHash}`);
const deleteButton = document.getElementById(`deleteNew${shortHash}`);
saveButton.id = `save-${data.id}`;
deleteButton.id = `delete-${data.id}`;
});
});
}
function saveExistingSentence(id) {
// get sentence value by
}
document.addEventListener('DOMContentLoaded', function () {
const timestampCells = document.querySelectorAll('.timestamp-cell');
console.log(timestampCells);
timestampCells.forEach(cell => {
const timestampStr = cell.textContent.trim();
const timestamp = parseFloat(timestampStr) * 1000;
cell.textContent = prettifyTimestamp(timestamp);
});
const addButton = document.getElementById('add-sentence');
addButton.addEventListener('click', () => {
// add a new empty row at the top of the tbody
const sentencesTable = document.getElementById('sentencesTable');
const newRow = sentencesTable.insertRow();
const nowPrettyDate = prettifyTimestamp(Date.now());
const shortHash = Math.random().toString(36).substring(2, 10);
newRow.innerHTML = `
<td><textarea placeholder="Enter new sentence" id="newSentence${shortHash}"></textarea></td>
<td class="timestamp-cell">${nowPrettyDate}</td>
<td>
<div class="container">
<button id="saveNew${shortHash}" style="background-color: #00c482; border-color: #00c482" onclick="saveNewSentence('${shortHash}')">
Save
</button>
<button id="deleteNew${shortHash}" style="border-color: #aa0000; background-color: #aa0000" onclick="this.parentElement.parentElement.parentElement.remove()">
Delete
</button>
</div>
</td>
`;
});
});
</script>
</header>
<main class="container">
<section>
<form role="search">
<input name="search" type="search" placeholder="Search sentence"/>
<input type="submit" value="Search"/>
<button id="add-sentence" class="secondary" type="button">Add</button>
</form>
<table id="sentencesTable">
<thead style="background-color: white; color: black">
<tr>
<th scope="col">Sentence</th>
<th scope="col">Last modified</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody id="sentencesTableBody">
{% for sentence in sentences %}
<tr>
<td><textarea>{{ sentence.sentence }}</textarea></td>
<td class="timestamp-cell">{{ sentence.last_updated_at }}</td>
<td>
<div class="container">
<button id="save-{{ sentence.id }}" style="background-color: #00c482; border-color: #00c482">
Save
</button>
<button id="delete-{{ sentence.id }}" style="border-color: #aa0000; background-color: #aa0000">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>

View file

@ -7,7 +7,7 @@ from gtts import gTTS
from litestar import WebSocket
from loguru import logger
from src.huesoporro.settings import Settings
from huesoporro.settings import Settings
class TTSManager:

View file

@ -6,11 +6,11 @@ from caribou.migrate import load_migrations
from polyfactory.factories.pydantic_factory import ModelFactory
from polyfactory.pytest_plugin import register_fixture
from src.huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, Quote, User
from src.huesoporro.settings import Settings
from src.huesoporro.svc.backoff_service import BackoffService
from src.huesoporro.svc.is_mod import IsModSvc
from huesoporro.infra.db import Database
from huesoporro.models import ChatbotSettings, Quote, User
from huesoporro.settings import Settings
from huesoporro.svc.backoff_service import BackoffService
from huesoporro.svc.is_mod import IsModSvc
@pytest.fixture

View file

@ -2,8 +2,8 @@ import json
import pytest
from src.huesoporro.infra.repos import QuoteRepo, UserRepo
from src.huesoporro.models import User
from huesoporro.infra.repos import QuoteRepo, UserRepo
from huesoporro.models import User
@pytest.fixture

View file

@ -3,8 +3,8 @@ import time
import pytest
from src.huesoporro.models import ChatbotSettings, User
from src.huesoporro.svc.is_mod import IsModSvc
from huesoporro.models import ChatbotSettings, User
from huesoporro.svc.is_mod import IsModSvc
async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User):

45
uv.lock generated
View file

@ -517,8 +517,8 @@ wheels = [
[[package]]
name = "huesoporro"
version = "0.2.9"
source = { virtual = "." }
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "caribou" },
@ -538,6 +538,10 @@ dependencies = [
]
[package.dev-dependencies]
cli = [
{ name = "typer" },
{ name = "yt-dlp" },
]
dev = [
{ name = "mypy" },
{ name = "polyfactory" },
@ -567,6 +571,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
cli = [
{ name = "typer", specifier = ">=0.15.1" },
{ name = "yt-dlp", specifier = ">=2025.1.26" },
]
dev = [
{ name = "mypy", specifier = ">=1.13.0" },
{ name = "polyfactory", specifier = ">=2.18.1" },
@ -1367,6 +1375,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "six"
version = "1.17.0"
@ -1450,6 +1467,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 },
]
[[package]]
name = "typer"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@ -1688,3 +1720,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 },
{ url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 },
]
[[package]]
name = "yt-dlp"
version = "2025.2.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/36/ef300ba4a228b74612d4013b43ed303a0d6d2de17a71fc37e0b821577e0a/yt_dlp-2025.2.19.tar.gz", hash = "sha256:f33ca76df2e4db31880f2fe408d44f5058d9f135015b13e50610dfbe78245bea", size = 2929199 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/45/6d1b759e68f5363b919828fb0e0c167a1cd5003b5b7c74cc0f0c2096be4f/yt_dlp-2025.2.19-py3-none-any.whl", hash = "sha256:3ed218eaeece55e9d715afd41abc450dc406ee63bf79355169dfde312d38fdb8", size = 3186543 },
]