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": {
"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": {

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -2,194 +2,41 @@
<body>
<header>
<nav id="navbar">
<nav class="container">
<ul>
<li><a href="/">Chatbot</a></li>
<li>TTS</li>
<li><a href="/lefunny">Le Funny</a></li>
{% include 'le_funny_dropdown.html' %}
</ul>
{% include 'logout.html' %}
</nav>
<h1>Huesoporro🦴🍃</h1>
</header>
<main>
<main class="container">
<section>
<form>
<label for="textInput">Enter text:</label>
<input type="text" id="textInput" placeholder="Hi huesoporro">
<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>
</section>
<audio id="audioPlayer" hidden="hidden" controls></audio>
<details open="open">
<details>
<summary>Log</summary>
<div><samp id="log"></samp></div>
</details>
</main>
<script>
document.addEventListener('DOMContentLoaded', () => {
class AudioStreamer {
constructor() {
this.url = getWebsocketProtocol() + window.location.host + "/ws";
this.audioPlayer = document.getElementById('audioPlayer');
this.logElement = document.getElementById('log');
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
}
log(message) {
console.log(message);
this.logElement.innerHTML += message + '<br>';
}
async start() {
// Establish WebSocket connection
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
this.log("Connecting to WebSocket: " + this.url);
this.websocket = new WebSocket(this.url);
this.websocket.withCredentials = true;
this.websocket.onopen = () => {
this.log('WebSocket connection established');
const logElement = document.getElementById('log');
const eventSource = new EventSource('/api/v1/sse/tts');
eventSource.onmessage = (event) => {
logElement.innerHTML += event.data + '<br>';
};
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>
</body>
</html>

View file

@ -2,7 +2,7 @@ import json
import pytest
from src.huesoporro.infra.repos import UserRepo
from src.huesoporro.infra.repos import QuoteRepo, UserRepo
from src.huesoporro.models import User
@ -17,6 +17,16 @@ async def user_repo(s, db, user: User):
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):
db_user = await user_repo.get_by_user(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):
assert await user_repo.delete(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"