From 75df191253987cd7d4b06c3b837b42430c7edd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Thu, 13 Feb 2025 09:52:15 +0100 Subject: [PATCH] feat: add GetRandomQuoteAction --- devenv.lock | 26 +-- devenv.nix | 13 -- src/huesoporro/actions/get_random_quote.py | 11 ++ src/huesoporro/api/routes/api.py | 2 - src/huesoporro/api/routes/auth.py | 12 +- src/huesoporro/bot.py | 20 ++- src/huesoporro/infra/gtts.py | 48 ++++++ src/huesoporro/infra/repos.py | 45 ++++- src/huesoporro/models.py | 15 ++ src/huesoporro/svc/get_random_quote.py | 9 +- src/huesoporro/templates/index.html | 2 +- src/huesoporro/templates/tts.html | 181 ++------------------- tests/test_repos.py | 19 ++- 13 files changed, 185 insertions(+), 218 deletions(-) create mode 100644 src/huesoporro/actions/get_random_quote.py create mode 100644 src/huesoporro/infra/gtts.py diff --git a/devenv.lock b/devenv.lock index 97f804a..d776fa9 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1735530587, + "lastModified": 1739362938, "owner": "cachix", "repo": "devenv", - "rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3", + "rev": "27276816caa1718f8b8e8d53d64cc18da059e101", "type": "github" }, "original": { @@ -101,35 +101,19 @@ "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1735286948, - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "31ac92f9628682b294026f0860e14587a09ffb4b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, "pre-commit-hooks": { "inputs": { "flake-compat": "flake-compat_2", "gitignore": "gitignore", "nixpkgs": [ "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" + ] }, "locked": { - "lastModified": 1734797603, + "lastModified": 1737465171, "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498", + "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", "type": "github" }, "original": { diff --git a/devenv.nix b/devenv.nix index eea6c6c..06edec4 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,24 +5,11 @@ packages = [ pkgs.git ]; - certificates = [ - "id.twitch.tv" - "twitch.tv" - "discord.com" - ]; - languages.python.enable = true; languages.python.uv.enable = true; languages.python.version = "3.12.8"; - scripts.hello.exec = '' - echo hello from $GREET - ''; - enterShell = '' - hello - git --version - fish ''; dotenv.enable = true; diff --git a/src/huesoporro/actions/get_random_quote.py b/src/huesoporro/actions/get_random_quote.py new file mode 100644 index 0000000..47c7b5b --- /dev/null +++ b/src/huesoporro/actions/get_random_quote.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.models import Quote +from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc + + +class GetRandomQuoteAction(BaseModel): + quote_getter_svc: RandomQuoteGetterSvc + + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_getter_svc.run(channel_name=channel_name) diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index 55e6557..607789f 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -32,8 +32,6 @@ async def get_tts_permalink(access_token: str) -> Template: """Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query param and not as a cookie, i.e. OBS""" - # authenticate the user using the provided access token - return Template( template_name="tts.html", ) diff --git a/src/huesoporro/api/routes/auth.py b/src/huesoporro/api/routes/auth.py index df95441..7d930b2 100644 --- a/src/huesoporro/api/routes/auth.py +++ b/src/huesoporro/api/routes/auth.py @@ -1,6 +1,7 @@ import secrets from litestar import MediaType, get +from litestar.datastructures.cookie import Cookie from litestar.response import Redirect, Template from src.huesoporro.actions.authenticate import AuthenticateAction @@ -10,7 +11,16 @@ from src.huesoporro.settings import Settings @get(path="/o/code") async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect: token = await authenticate_action.run(code) - return Redirect("/", cookies={"huesoporroAuth": token}) + return Redirect( + "/", + cookies=[ + Cookie( + key="huesoporroAuth", + value=token, + expires=604800, # 1 week + ) + ], + ) @get( diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index 025a2dd..f682260 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -7,8 +7,11 @@ 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 @@ -33,8 +36,11 @@ class Bot(commands.Bot): self.store_quote_action = StoreQuoteAction( quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db) ) - - self.get_random_quote_svc = RandomQuoteGetterSvc(db=db) + self.quote_repo = QuoteRepo(s=get_settings()) + 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 + ) self.cbs = chatbot_settings self.quote_routine = routines.routine( seconds=chatbot_settings.automatic_quote_timer, wait_first=True @@ -78,19 +84,19 @@ class Bot(commands.Bot): @commands.command(aliases=["q", "quote"]) async def get_random_quote(self, ctx: commands.Context): - quote = await self.get_random_quote_svc.run(channel_name=self.channel) + quote = await self.get_random_quote_action.run(channel_name=self.channel) if quote: - await ctx.send(f"«{quote[0]}» - {quote[1]}") + await ctx.send(quote.as_pretty()) def get_channel_conn(self) -> Channel: return Channel(name=self.channel, websocket=self._connection) async def send_quote(self): - quote = await self.get_random_quote_svc.run(channel_name=self.channel) + 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[0]}") - await channel.send(f"«{quote[0]}» - {quote[1]}") + logger.info(f"Sending random quote {quote.quote}") + await channel.send(quote.quote) async def send_generation(self): sentence = await self.generate_svc.run() diff --git a/src/huesoporro/infra/gtts.py b/src/huesoporro/infra/gtts.py new file mode 100644 index 0000000..10814ec --- /dev/null +++ b/src/huesoporro/infra/gtts.py @@ -0,0 +1,48 @@ +from collections import deque +from hashlib import sha512 +from pathlib import Path + +from gtts import gTTS +from loguru import logger +from pydantic import BaseModel + +from src.huesoporro.settings import Settings + + +class GTTS(BaseModel): + s: Settings + chunk_size: int = 128 + text_max_length: int = 100 + queue: deque = deque() + + async def generate(self, text: str, lang: str = "pt", tld="com.br") -> Path: + text = text[: self.text_max_length] + raw_filename = f"{text.lower()}_{lang}_{tld}" + logger.info(f"Generating TTS for {raw_filename}") + filepath = ( + self.s.tts_cache_path / f"{sha512(raw_filename.encode()).hexdigest()}.mp3" + ) + tts = gTTS(text=text, lang=lang, tld=tld) + logger.info(f"Saving TTS to {filepath}") + tts.save(str(filepath)) + self.queue.append(filepath) + return filepath + + async def consume(self): + """If there are items in the queue, return a generator + that reads the file's bytes by chunks of self.chunk_size""" + while self.queue: + filepath = self.queue.popleft() + if not filepath.exists(): + logger.warning(f"File {filepath} does not exist, skipping") + continue + + logger.info(f"Reading file {filepath}") + try: + with filepath.open("rb") as f: + while chunk := f.read(self.chunk_size): + yield chunk + logger.info(f"Finished reading {filepath}") + except Exception as e: # noqa: BLE001 + logger.error(f"Error reading file {filepath}: {e}") + continue diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py index 7e4e939..a4aab5b 100644 --- a/src/huesoporro/infra/repos.py +++ b/src/huesoporro/infra/repos.py @@ -6,7 +6,7 @@ from typing import Generic, TypeVar import aiosqlite from pydantic import BaseModel, Field -from src.huesoporro.models import User +from src.huesoporro.models import Quote, User from src.huesoporro.settings import Settings T = TypeVar("T", bound=BaseModel) @@ -112,3 +112,46 @@ class UserRepo(IRepo[User]): async def count(self, obj: User, auto_commit=True): raise NotImplementedError("Not implemented since it's not needed") + + +class QuoteRepo(IRepo[Quote]): + async def create(self, obj: Quote, auto_commit=True) -> Quote: + raise NotImplementedError("Not implemented since it's not needed") + + async def update(self, obj: Quote, auto_commit=True) -> Quote: + raise NotImplementedError("Not implemented since it's not needed") + + async def delete(self, obj: Quote, auto_commit=True): + raise NotImplementedError("Not implemented since it's not needed") + + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> Quote | None: + raise NotImplementedError("Not implemented since it's not needed") + + async def list( + self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True + ) -> list[T]: + raise NotImplementedError("Not implemented since it's not needed") + + async def get_random(self, channel_name: str, auto_commit=True) -> Quote | None: + async with ( + self.get_client(auto_commit=auto_commit) as db, + db.execute( + """ + SELECT * FROM quotes + WHERE channel = ? + ORDER BY RANDOM() + LIMIT 1 + """, + (channel_name,), + ) as cursor, + ): + data = await cursor.fetchone() + if not data: + return None + return Quote( + quote=data["quote"], + author=User(user=data["author"], external_auth={}), + channel=User(user=data["channel"], external_auth={}), + created_at=data["created_at"], + last_updated_at=data["last_updated_at"], + ) diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index f791cf6..7222887 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -1,3 +1,4 @@ +import datetime from typing import Literal import jwt @@ -69,3 +70,17 @@ class Sentence(BaseModel): created_at: float last_updated_at: float user: User + + +class Quote(BaseModel): + quote: str + author: User + channel: User + created_at: datetime.datetime + last_updated_at: datetime.datetime + + def as_pretty(self) -> str: + return f"«{self.quote}» - {self.author}" + + def as_pretty_saved(self): + return f"He añadido la cita «{self.quote}» de {self.author}" diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py index f6b4f6c..055b633 100644 --- a/src/huesoporro/svc/get_random_quote.py +++ b/src/huesoporro/svc/get_random_quote.py @@ -1,10 +1,11 @@ from pydantic import BaseModel -from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import QuoteRepo +from src.huesoporro.models import Quote class RandomQuoteGetterSvc(BaseModel): - db: Database + quote_repo: QuoteRepo - async def run(self, channel_name: str) -> tuple[str, str] | None: - return await self.db.get_random_quote(channel_name=channel_name) + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_repo.get_random(channel_name=channel_name) diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index e7ffa8c..082c17e 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -5,7 +5,7 @@