feat: add GetRandomQuoteAction

This commit is contained in:
cătălin 2025-02-13 09:52:15 +01:00
commit 75df191253
No known key found for this signature in database
13 changed files with 185 additions and 218 deletions

View file

@ -3,10 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1735530587, "lastModified": 1739362938,
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3", "rev": "27276816caa1718f8b8e8d53d64cc18da059e101",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -101,35 +101,19 @@
"type": "github" "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": { "pre-commit-hooks": {
"inputs": { "inputs": {
"flake-compat": "flake-compat_2", "flake-compat": "flake-compat_2",
"gitignore": "gitignore", "gitignore": "gitignore",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
], ]
"nixpkgs-stable": "nixpkgs-stable"
}, },
"locked": { "locked": {
"lastModified": 1734797603, "lastModified": 1737465171,
"owner": "cachix", "owner": "cachix",
"repo": "pre-commit-hooks.nix", "repo": "pre-commit-hooks.nix",
"rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498", "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -5,24 +5,11 @@
packages = [ pkgs.git ]; packages = [ pkgs.git ];
certificates = [
"id.twitch.tv"
"twitch.tv"
"discord.com"
];
languages.python.enable = true; languages.python.enable = true;
languages.python.uv.enable = true; languages.python.uv.enable = true;
languages.python.version = "3.12.8"; languages.python.version = "3.12.8";
scripts.hello.exec = ''
echo hello from $GREET
'';
enterShell = '' enterShell = ''
hello
git --version
fish
''; '';
dotenv.enable = true; dotenv.enable = true;

View file

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

View file

@ -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 """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""" param and not as a cookie, i.e. OBS"""
# authenticate the user using the provided access token
return Template( return Template(
template_name="tts.html", template_name="tts.html",
) )

View file

@ -1,6 +1,7 @@
import secrets import secrets
from litestar import MediaType, get from litestar import MediaType, get
from litestar.datastructures.cookie import Cookie
from litestar.response import Redirect, Template from litestar.response import Redirect, Template
from src.huesoporro.actions.authenticate import AuthenticateAction from src.huesoporro.actions.authenticate import AuthenticateAction
@ -10,7 +11,16 @@ from src.huesoporro.settings import Settings
@get(path="/o/code") @get(path="/o/code")
async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect: async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect:
token = await authenticate_action.run(code) token = await authenticate_action.run(code)
return Redirect("/", cookies={"huesoporroAuth": token}) return Redirect(
"/",
cookies=[
Cookie(
key="huesoporroAuth",
value=token,
expires=604800, # 1 week
)
],
)
@get( @get(

View file

@ -7,8 +7,11 @@ from loguru import logger
from twitchio import Channel from twitchio import Channel
from twitchio.ext import commands, routines 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.actions.store_quote import StoreQuoteAction
from src.huesoporro.api.dependencies import get_settings
from src.huesoporro.infra.db import Database 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.libs.db import Database as MarkovDB
from src.huesoporro.models import ChatbotSettings, User from src.huesoporro.models import ChatbotSettings, User
from src.huesoporro.svc.backoff_service import BackoffService from src.huesoporro.svc.backoff_service import BackoffService
@ -33,8 +36,11 @@ class Bot(commands.Bot):
self.store_quote_action = StoreQuoteAction( self.store_quote_action = StoreQuoteAction(
quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db) quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db)
) )
self.quote_repo = QuoteRepo(s=get_settings())
self.get_random_quote_svc = RandomQuoteGetterSvc(db=db) 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.cbs = chatbot_settings
self.quote_routine = routines.routine( self.quote_routine = routines.routine(
seconds=chatbot_settings.automatic_quote_timer, wait_first=True seconds=chatbot_settings.automatic_quote_timer, wait_first=True
@ -78,19 +84,19 @@ class Bot(commands.Bot):
@commands.command(aliases=["q", "quote"]) @commands.command(aliases=["q", "quote"])
async def get_random_quote(self, ctx: commands.Context): 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: if quote:
await ctx.send(f"«{quote[0]}» - {quote[1]}") await ctx.send(quote.as_pretty())
def get_channel_conn(self) -> Channel: def get_channel_conn(self) -> Channel:
return Channel(name=self.channel, websocket=self._connection) return Channel(name=self.channel, websocket=self._connection)
async def send_quote(self): 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: if quote:
channel = self.get_channel_conn() channel = self.get_channel_conn()
logger.info(f"Sending random quote {quote[0]}") logger.info(f"Sending random quote {quote.quote}")
await channel.send(f"«{quote[0]}» - {quote[1]}") await channel.send(quote.quote)
async def send_generation(self): async def send_generation(self):
sentence = await self.generate_svc.run() sentence = await self.generate_svc.run()

View file

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

View file

@ -6,7 +6,7 @@ from typing import Generic, TypeVar
import aiosqlite import aiosqlite
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.huesoporro.models import User from src.huesoporro.models import Quote, User
from src.huesoporro.settings import Settings from src.huesoporro.settings import Settings
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -112,3 +112,46 @@ class UserRepo(IRepo[User]):
async def count(self, obj: User, auto_commit=True): async def count(self, obj: User, auto_commit=True):
raise NotImplementedError("Not implemented since it's not needed") 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"],
)

View file

@ -1,3 +1,4 @@
import datetime
from typing import Literal from typing import Literal
import jwt import jwt
@ -69,3 +70,17 @@ class Sentence(BaseModel):
created_at: float created_at: float
last_updated_at: float last_updated_at: float
user: User 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}"

View file

@ -1,10 +1,11 @@
from pydantic import BaseModel 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): class RandomQuoteGetterSvc(BaseModel):
db: Database quote_repo: QuoteRepo
async def run(self, channel_name: str) -> tuple[str, str] | None: async def run(self, channel_name: str) -> Quote | None:
return await self.db.get_random_quote(channel_name=channel_name) return await self.quote_repo.get_random(channel_name=channel_name)

View file

@ -5,7 +5,7 @@
<nav class="container"> <nav class="container">
<ul> <ul>
<li>Chatbot</li> <li>Chatbot</li>
<li><a href="#" disabled>TTS</a></li> <li><a href="/tts" disabled>TTS</a></li>
{% include 'le_funny_dropdown.html' %} {% include 'le_funny_dropdown.html' %}
</ul> </ul>
{% include 'logout.html' %} {% include 'logout.html' %}

View file

@ -2,194 +2,41 @@
<body> <body>
<header> <header>
<nav id="navbar"> <nav class="container">
<ul> <ul>
<li><a href="/">Chatbot</a></li> <li><a href="/">Chatbot</a></li>
<li>TTS</li> <li>TTS</li>
<li><a href="/lefunny">Le Funny</a></li> {% include 'le_funny_dropdown.html' %}
</ul> </ul>
{% include 'logout.html' %} {% include 'logout.html' %}
</nav> </nav>
<h1>Huesoporro🦴🍃</h1>
</header> </header>
<main> <main class="container">
<section> <section>
<form> <form>
<label for="textInput">Enter text:</label> <label for="textInput">Enter text:</label>
<input type="text" id="textInput" placeholder="Hi huesoporro"> <input type="text" id="textInput" placeholder="Hi huesoporro">
<button id="sendButton" type="button">Send text</button> <button id="sendButton" type="button">Send text</button>
<button id="genPermalinkButton" type="button" style="background-color: #9c36b5; border-color: #9c36b5">Generate OBS Link</button> <button id="genPermalinkButton" type="button" style="background-color: #9c36b5; border-color: #9c36b5">
Generate OBS Link
</button>
</form> </form>
</section> </section>
<audio id="audioPlayer" hidden="hidden" controls></audio> <audio id="audioPlayer" hidden="hidden" controls></audio>
<details open="open"> <details>
<summary>Log</summary> <summary>Log</summary>
<div><samp id="log"></samp></div> <div><samp id="log"></samp></div>
</details> </details>
</main> </main>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
class AudioStreamer { const logElement = document.getElementById('log');
constructor() { const eventSource = new EventSource('/api/v1/sse/tts');
this.url = getWebsocketProtocol() + window.location.host + "/ws"; eventSource.onmessage = (event) => {
this.audioPlayer = document.getElementById('audioPlayer'); logElement.innerHTML += event.data + '<br>';
this.logElement = document.getElementById('log');
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
}
log(message) {
console.log(message);
this.logElement.innerHTML += message + '<br>';
}
async start() {
// Establish WebSocket connection
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
this.log("Connecting to WebSocket: " + this.url);
this.websocket = new WebSocket(this.url);
this.websocket.withCredentials = true;
this.websocket.onopen = () => {
this.log('WebSocket connection established');
}; };
this.websocket.onmessage = async (event) => {
try {
if (typeof event.data === 'string') {
if (event.data.startsWith('FILE_HEADER:')) {
this.expectedFileSize = parseInt(event.data.split(':')[1]);
this.log(`Expecting file of size: ${this.expectedFileSize} bytes`);
this.audioBuffer = [];
this.receivedFileSize = 0;
} else if (event.data === 'FILE_FOOTER') {
// Only play after file footer is received and all chunks are in
this.log(`Received complete file. Total size: ${this.receivedFileSize} bytes`);
if (this.receivedFileSize > 0) {
await this.playAudioBuffer();
} else {
this.log('No audio data received');
}
}
} else {
// Accumulate chunks
const audioData = await event.data.arrayBuffer();
this.audioBuffer.push(audioData);
this.receivedFileSize += audioData.byteLength;
this.log(`Received chunk. Total received: ${this.receivedFileSize} / ${this.expectedFileSize}`);
if (this.receivedFileSize >= this.expectedFileSize) {
// Play audio when complete
await this.playAudioBuffer();
}
}
} catch (error) {
this.log(`Error processing audio: ${error}`);
}
};
this.websocket.onerror = (error) => {
this.log(`WebSocket error: ${error}`);
};
}
async combineBuffers(buffers) {
console.log(`Combining ${buffers.length} buffers`);
buffers.forEach((buffer, index) => {
console.log(`Buffer ${index} size: ${buffer.byteLength} bytes`);
});
// Validate buffers
if (buffers.length === 0) {
console.error('No buffers to combine');
return new ArrayBuffer(0);
}
// Calculate total length
const totalLength = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0);
console.log(`Total combined length: ${totalLength} bytes`);
// Create a new buffer and copy data
const combinedBuffer = new Uint8Array(totalLength);
let offset = 0;
for (const buffer of buffers) {
combinedBuffer.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return combinedBuffer.buffer;
}
async playAudioBuffer() {
try {
const combinedBuffer = await this.combineBuffers(this.audioBuffer);
// Verify combined buffer
console.log(`Combined buffer size: ${combinedBuffer.byteLength} bytes`);
const blob = new Blob([combinedBuffer], {type: 'audio/mpeg'});
console.log(`Blob size: ${blob.size} bytes`);
// Only proceed if blob has content
if (blob.size > 0) {
this.audioPlayer.src = URL.createObjectURL(blob);
await this.audioPlayer.play();
} else {
console.error('Blob is empty');
}
} catch (error) {
console.error('Audio buffer processing error:', error);
}
}
sendText(text) {
// build a Websocket message and send it
const message = {
"command": "tts_send",
"data": {
"text": text
}
}
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(message));
}
}
}
const audioStreamer = new AudioStreamer();
const sendButton = document.getElementById('sendButton');
const textInput = document.getElementById('textInput');
// Automatically connect on page load
audioStreamer.start();
sendButton.addEventListener('click', () => {
const text = textInput.value.trim();
if (text) {
audioStreamer.sendText(text);
textInput.value = ''; // Clear input
}
});
addLogoutEvent()
const genPermalinkButton = document.getElementById('genPermalinkButton');
genPermalinkButton.addEventListener('click', () => {
// generate <ur>/tts/permalink?access_token=<access_token>
// the access token is available in the twitchLoginData cookie
const cookie = JSON.parse(getCookie("huesoporroAuth"))
const permalinkUrl = `${window.location.origin}/tts/permalink?access_token=${cookie.access_token}`;
navigator.clipboard.writeText(permalinkUrl);
alert('OBS link copied to clipboard ' + permalinkUrl);
}) })
});
</script> </script>
</body> </body>
</html> </html>

View file

@ -2,7 +2,7 @@ import json
import pytest import pytest
from src.huesoporro.infra.repos import UserRepo from src.huesoporro.infra.repos import QuoteRepo, UserRepo
from src.huesoporro.models import User from src.huesoporro.models import User
@ -17,6 +17,16 @@ async def user_repo(s, db, user: User):
return UserRepo(s=s) return UserRepo(s=s)
@pytest.fixture
async def quote_repo(s, db):
async with db.get_client() as client:
await client.execute(
"INSERT INTO quotes (channel, quote, author) VALUES (?, ?, ?)",
("channel", "quote", "author"),
)
return QuoteRepo(s=s)
async def test_get_user(user_repo: UserRepo, user: User): async def test_get_user(user_repo: UserRepo, user: User):
db_user = await user_repo.get_by_user(user.user) db_user = await user_repo.get_by_user(user.user)
assert db_user == user assert db_user == user
@ -51,3 +61,10 @@ async def test_update_non_existing_user_raises_value_error(user_repo: UserRepo):
async def test_delete_user(user_repo: UserRepo, user: User): async def test_delete_user(user_repo: UserRepo, user: User):
assert await user_repo.delete(user) is None assert await user_repo.delete(user) is None
assert await user_repo.get_by_user(user.user) is None assert await user_repo.get_by_user(user.user) is None
async def test_get_random_quote(quote_repo: QuoteRepo):
quote = await quote_repo.get_random("channel")
assert quote
assert quote.author.user == "author"
assert quote.channel.user == "channel"