feat: add migrations, api bot endpoints and revamp the whole twitch backend by making use of twitchio

This commit is contained in:
cătălin 2024-12-17 17:55:02 +01:00
commit 4c534de47b
No known key found for this signature in database
45 changed files with 1718 additions and 1109 deletions

13
.gitignore vendored
View file

@ -114,18 +114,5 @@ src/huesoporro/tts_files/
# Devenv # Devenv
.devenv* .devenv*
devenv.local.nix devenv.local.nix
# direnv # direnv
.direnv .direnv
# pre-commit
.pre-commit-config.yaml
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View file

@ -32,8 +32,13 @@ COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./
RUN uv sync RUN uv sync
COPY --chown=$USERNAME src/ src/ COPY --chown=$USERNAME src/ src/
COPY --chown=$USERNAME migrations/ migrations/
FROM base AS serve FROM base AS serve
CMD ["make", "serve"] CMD ["make", "serve"]
FROM base AS migrate
CMD ["make", "migrate"]

View file

@ -16,3 +16,6 @@ serve:
build: build:
docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET) docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET)
migrate:
uv run caribou upgrade ~/.local/share/huesoporro/huesoporro.db migrations/

View file

@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # 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. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.1 version: 0.2.2
# This is the version number of the application being deployed. This version number should be # 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 # 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. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "0.2.1" appVersion: "0.2.2"

View file

@ -40,6 +40,17 @@ spec:
mountPath: /data mountPath: /data
securityContext: securityContext:
runAsUser: 0 runAsUser: 0
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- make
- migrate
{{- if .Values.persistence.enabled }}
volumeMounts:
- name: data
mountPath: /home/huesoporro/.local/share/huesoporro
{{- end }}
{{- end }} {{- end }}
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}

View file

@ -11,7 +11,7 @@ image:
# This sets the pull policy for images. # This sets the pull policy for images.
pullPolicy: Always pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion. # Overrides the image tag whose default is the chart appVersion.
tag: "0.2.1" tag: "0.2.2"
# 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/ # 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: [] imagePullSecrets: []

View file

@ -0,0 +1,29 @@
"""
This module contains a Caribou migration.
Migration Name: auth
Migration Version: 20241213175820
"""
def upgrade(connection):
# add your upgrade step here
sql = """
create table users
(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user varchar(255) NOT NULL UNIQUE,
access_token varchar(255) NOT NULL,
refresh_token varchar(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -0,0 +1,38 @@
"""
This module contains a Caribou migration.
Migration Name: quotes
Migration Version: 20241216204252
"""
def upgrade(connection):
# add your upgrade step here
sql = """
create table quotes
(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
quote varchar(255) NOT NULL UNIQUE,
author varchar(255),
channel varchar(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
connection.execute(sql)
sql = """
create table sentences
(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
sentence varchar(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -0,0 +1,28 @@
"""
This module contains a Caribou migration.
Migration Name: settings
Migration Version: 20241217000747
"""
def upgrade(connection):
sql = """
create table settings(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id VARCHAR(255) NOT NULL UNIQUE,
automatic_generation_timer INTENGER NOT NULL DEFAULT 300,
automatic_quote_timer INTEGER NOT NULL DEFAULT 500,
mods VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user)
);
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -1,6 +1,6 @@
[project] [project]
name = "huesoporro" name = "huesoporro"
version = "0.2.1" version = "0.2.2"
description = "Misc Twitch bots" description = "Misc Twitch bots"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@ -20,6 +20,13 @@ dependencies = [
"gtts>=2.5.4", "gtts>=2.5.4",
"litestar[standard]>=2.13.0", "litestar[standard]>=2.13.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"caribou>=0.4.1",
"aiosqlite>=0.20.0",
"pyjwt>=2.10.1",
"huey>=2.5.2",
"twitchio>=2.10.0",
"redis>=5.2.1",
"pytest>=8.3.4",
] ]
[tool.uv] [tool.uv]

View file

View file

@ -0,0 +1,18 @@
from pydantic import BaseModel
from src.huesoporro.models import User
from src.huesoporro.svc.is_mod import IsModSvc
from src.huesoporro.svc.store_quote import QuoteStorerSvc
class StoreQuoteAction(BaseModel):
quote_storer_svc: QuoteStorerSvc
is_mod_svc: IsModSvc
async def run(
self, user: User, channel: str, quote: str, author: str, username: str
) -> str:
if not await self.is_mod_svc.run(user=user, username=username):
return f"{username} is not a mod and cannot add quotes. Only moderators can add quotes. Sorry!"
await self.quote_storer_svc.run(channel, quote, author)
return f"«{quote}» added by {author}."

View file

View file

@ -0,0 +1,48 @@
from litestar import Request
from litestar.exceptions import HTTPException
from src.huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.db import Database
from src.huesoporro.models import User
from src.huesoporro.settings import Settings
from src.huesoporro.svc.authenticate import CodeAuthenticatorSvc
from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
def get_settings() -> Settings:
return Settings.get()
def get_authenticator(s: Settings) -> TwitchAuthenticator:
return TwitchAuthenticator(s=s)
def get_db(s: Settings):
return Database(s=s)
async def authenticate(request: Request) -> User:
token = request.query_params.get("huesoporro_token")
if token:
return User.decode(token)
cookies = request.cookies.get("huesoporroAuth")
if cookies:
return User.decode(cookies)
raise HTTPException(status_code=401, detail="Unauthorized")
async def get_code_authenticator_svc(
a: TwitchAuthenticator, db: Database
) -> CodeAuthenticatorSvc:
return CodeAuthenticatorSvc(authenticator=a, db=db)
async def get_chatbot_settings_svc(db: Database):
return ChatbotSettingsGetterSvc(db=db)
async def store_chatbot_settings_svc(db: Database):
return ChatbotSettingsStorerSvc(db=db)

View file

@ -0,0 +1,45 @@
import httpx
from litestar import MediaType, Request, Response
from litestar.exceptions import HTTPException
from litestar.response import Redirect
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
from loguru import logger
def http_exception_handler(_: Request, exc: HTTPException) -> Response:
status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
detail = getattr(exc, "detail", "")
if isinstance(exc, HTTPException) and (exc.status_code in [401, 403]):
logger.warning("User could not authenticate. Redirecting to /login page")
return Redirect("/login")
return Response(
media_type=MediaType.TEXT,
content=detail,
status_code=status_code,
)
def httpx_status_error_handler(_: Request, exc: httpx.HTTPStatusError):
logger.error(f"HTTPX error occurred: {exc}")
return Response(
media_type=MediaType.TEXT,
content=f"HTTPX error occurred: {exc}",
status_code=exc.response.status_code,
)
async def after_exception_handler(exc: Exception, scope: "Scope") -> None:
"""Hook function that will be invoked after each exception."""
state = scope["app"].state
if not hasattr(state, "error_count"):
state.error_count = 1
else:
state.error_count += 1
logger.error(
f"an exception of type {type(exc).__name__} has occurred for requested path {scope['path']} and the application error count is {state.error_count}.",
)
import traceback
traceback.print_exc()

View file

@ -0,0 +1,85 @@
import httpx
from litestar import Litestar, get
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.di import Provide
from litestar.exceptions import HTTPException
from litestar.static_files import StaticFilesConfig
from litestar.template import TemplateConfig
from src.huesoporro.api.dependencies import (
authenticate,
get_authenticator,
get_chatbot_settings_svc,
get_code_authenticator_svc,
get_db,
get_settings,
store_chatbot_settings_svc,
)
from src.huesoporro.api.errors import (
after_exception_handler,
http_exception_handler,
httpx_status_error_handler,
)
from src.huesoporro.api.routes.api import (
get_bot_settings,
get_bot_status,
get_index,
get_tts_overlay,
get_tts_permalink,
manage_bot,
save_bot_settings,
)
from src.huesoporro.api.routes.auth import get_code, login
from src.huesoporro.bot import BotsManager
from src.huesoporro.settings import Settings
@get("/healthz")
def get_health() -> dict:
return {"status": "ok"}
def create_app():
return Litestar(
route_handlers=[
get_health,
login,
get_index,
get_tts_overlay,
get_tts_permalink,
get_code,
manage_bot,
get_bot_status,
save_bot_settings,
get_bot_settings,
],
static_files_config=(
StaticFilesConfig(
path="/tts_files",
directories=[Settings.get().tts_cache_path],
),
StaticFilesConfig(
path="static",
directories=[Settings.get().static_files_path],
),
),
template_config=TemplateConfig(
directory=Settings.get().templates_files_path,
engine=JinjaTemplateEngine,
),
exception_handlers={
HTTPException: http_exception_handler,
httpx.HTTPStatusError: httpx_status_error_handler,
},
after_exception=[after_exception_handler],
dependencies={
"s": Provide(get_settings, use_cache=True),
"a": Provide(get_authenticator, use_cache=True),
"user": Provide(authenticate),
"db": Provide(get_db, use_cache=True),
"code_authenticator_svc": Provide(get_code_authenticator_svc),
"bm": Provide(BotsManager, use_cache=True),
"gbs": Provide(get_chatbot_settings_svc),
"sbs": Provide(store_chatbot_settings_svc),
},
)

View file

View file

@ -0,0 +1,86 @@
from typing import Literal
from litestar import MediaType, Response, get, put
from litestar.response import Template
from pydantic import BaseModel
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.store_settings import ChatbotSettingsStorerSvc
class ManageBotDTO(BaseModel):
command: Literal["start", "stop"]
channel_name: str | None = None
@get(
"/tts",
media_type=MediaType.HTML,
)
async def get_tts_overlay() -> Template:
return Template(template_name="tts.html")
@get(
"/tts/permalink",
media_type=MediaType.HTML,
)
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",
)
@get(
"/",
media_type=MediaType.HTML,
)
async def get_index(user: User, gbs: ChatbotSettingsGetterSvc) -> Template:
chatbot_settings = await gbs.run(user=user)
return Template(template_name="index.html", context=chatbot_settings.model_dump() if chatbot_settings else {})
@put("/api/v1/bot")
async def manage_bot(
user: User, data: ManageBotDTO, gbs: ChatbotSettingsGetterSvc, bm: BotsManager
) -> Response:
chatbot_settings = await gbs.run(user=user)
if data.command == "start":
if not data.channel_name:
return Response({"message": "Channel name is required"}, status_code=400)
bm.add_bot(user, data.channel_name, chatbot_settings=chatbot_settings)
if user.user in bm.bots:
await bm.run_user_bot(user)
return Response({"message": "Bot started"})
if data.command == "stop" and user.user in bm.bots:
await bm.stop_user_bot(user)
return Response({"message": "Bot stopped"})
@get("/api/v1/bot")
async def get_bot_status(user: User, bm: BotsManager) -> dict:
if user.user not in bm.bots:
return {"status": "ko"}
return {"status": "ok"}
@get("/api/v1/bot/settings")
async def get_bot_settings(
user: User, gbs: ChatbotSettingsGetterSvc
) -> ChatbotSettings:
return await gbs.run(user=user)
@put("/api/v1/bot/settings")
async def save_bot_settings(
user: User, data: ChatbotSettings, sbs: ChatbotSettingsStorerSvc
) -> dict:
await sbs.run(user=user, bot_settings=data)
return {"status": "ok"}

View file

@ -0,0 +1,31 @@
import secrets
from litestar import MediaType, get
from litestar.response import Redirect, Template
from src.huesoporro.settings import Settings
from src.huesoporro.svc.authenticate import CodeAuthenticatorSvc
@get(path="/o/code")
async def get_code(code: str, code_authenticator_svc: CodeAuthenticatorSvc) -> Redirect:
user = await code_authenticator_svc.run(code)
return Redirect("/", cookies={"huesoporroAuth": user.encode()})
@get(
"/login",
media_type=MediaType.HTML,
)
async def login(s: Settings) -> Template:
scopes = "+".join(s.twitch_scopes)
return Template(
"login.html",
context={
"twitch_login_url": "https://id.twitch.tv/oauth2/authorize?response_type=code"
f"&client_id={s.twitch_client_id}"
f"&redirect_uri={s.server_hostname}o/code"
f"&scope={scopes}"
f"&state={secrets.token_urlsafe(32)}"
},
)

150
src/huesoporro/bot.py Normal file
View file

@ -0,0 +1,150 @@
import asyncio
from loguru import logger
from twitchio import Channel
from twitchio.ext import commands, routines
from src.huesoporro.actions.store_quote import StoreQuoteAction
from src.huesoporro.infra.db import Database
from src.huesoporro.libs.db import Database as MarkovDB
from src.huesoporro.models import ChatbotSettings, User
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.store import SentenceStorerSvc
from src.huesoporro.svc.store_quote import QuoteStorerSvc
class Bot(commands.Bot):
def __init__(self, user: User, chatbot_settings: ChatbotSettings, channel: str):
super().__init__(
token=user.twitch_auth.access_token, prefix="!", initial_channels=[channel]
)
self.channel = channel
self.user = user
self.generate_svc = SentenceGeneratorSvc(db=MarkovDB(channel=channel))
self.hello_svc = HelloGeneratorSvc()
db = Database()
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_routine = routines.routine(
seconds=chatbot_settings.automatic_quote_timer, wait_first=True
)(self.send_quote)
self.generation_routine = routines.routine(
seconds=chatbot_settings.automatic_generation_timer, wait_first=True
)(self.send_generation)
async def event_ready(self):
logger.info(f"Logged in as {self.nick}")
logger.info(f"User id is {self.user_id}")
@commands.command()
async def hello(self, ctx: commands.Context, user: User | None = None):
username = user.name if user else 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)
if sentence:
await ctx.send(sentence)
@commands.command(aliases=["qadd"])
async def add_quote(self, ctx: commands.Context, *, quote: str):
# extract author from quote; the author is the last word
quote, author = quote.rsplit(" ", 1)
await ctx.send(
await self.store_quote_action.run(
user=self.user,
channel=self.channel,
quote=quote,
author=author,
username=ctx.author.name,
)
)
@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)
await ctx.send(f"«{quote[0]}» - {quote[1]}")
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)
channel = self.get_channel_conn()
logger.info(f"Sending random quote {quote[0]}")
await channel.send(f"«{quote[0]}» - {quote[1]}")
async def send_generation(self):
sentence = await self.generate_svc.run()
if not sentence:
return
channel = self.get_channel_conn()
logger.info(f"Sending generated sentence {sentence}")
await channel.send(sentence)
def start_routines(self):
logger.info("Starting routines")
self.quote_routine.start(stop_on_error=False)
self.generation_routine.start(stop_on_error=False)
def stop_routines(self):
logger.info("Stopping routines")
self.quote_routine.cancel()
self.generation_routine.cancel()
class SaveMessagesCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel))
@commands.Cog.event()
async def event_message(self, message):
# An event inside a cog!
content = message.content
if content.startswith("!"):
return
if not message.author:
return
await self.store_svc.run(content)
class BotsManager:
def __init__(self):
self.bots: dict[str, Bot] = {}
def add_bot(self, user: User, channel: str, chatbot_settings: ChatbotSettings):
if user.user in self.bots:
logger.info(f"Bot for {user.user} already exists")
return
logger.info(f"Adding bot for {user.user}")
bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings)
bot.add_cog(SaveMessagesCog(bot))
self.bots[user.user] = bot
async def run_user_bot(self, user: User):
if user.user not in self.bots:
return
logger.info(f"Starting bot for {user.user}")
bot = self.bots[user.user]
task = asyncio.create_task(bot.start())
task.add_done_callback(lambda x: logger.info(f"Bot for {user.user} stopped"))
bot.start_routines()
async def stop_user_bot(self, user: User):
if user.user not in self.bots:
return
bot = self.bots.pop(user.user)
await bot.close()
bot.stop_routines()

View file

@ -1,62 +0,0 @@
import asyncio
from asyncio import sleep as asleep
from queue import Queue
from time import sleep
import nltk
from litestar import WebSocket
from loguru import logger
from src.huesoporro.libs.markov_chain_bot import MarkovChain
from src.huesoporro.libs.settings import Settings as MarkovChainSettings
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
nltk.download("punkt_tab")
class ChatbotManager:
def __init__(self):
self.bot: MarkovChain | None = None
self.clients: set[WebSocket] = set()
self.log_queue: Queue = Queue()
self.tasks: set = set()
def start_bot(
self,
channel_name: str,
nickname: str,
authentication: str,
):
task = asyncio.create_task(self.send_bot_status())
self.tasks.add(task)
if self.bot:
return
self.bot = MarkovChain(
settings=MarkovChainSettings(
Channel=channel_name,
Nickname=nickname,
Authentication=authentication,
AutomaticGenerationTimer=300,
),
)
self.bot.run_bot()
sleep(2)
def stop_bot(self):
self.bot.stop_bot()
self.bot = None
async def send_bot_status(self):
while True:
for client in self.clients:
message = WebsocketMessage(
command=WebsocketCommands.CHATBOT_STATUS,
data={"status": "ok" if self.bot else "ko"},
)
await client.send_text(message.model_dump_json())
logger.info(
f"Sending bot status {message} to {client.client.host}:{client.client.port}"
)
await asleep(2)

View file

View file

@ -0,0 +1,62 @@
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
class TwitchAuthenticator(BaseModel):
s: Settings = Field(default_factory=Settings.get)
client: httpx.AsyncClient = Field(
default_factory=lambda x: httpx.AsyncClient(base_url="https://id.twitch.tv/")
)
model_config = ConfigDict(arbitrary_types_allowed=True)
async def get_token(self, code: str, auto_refresh: bool = True) -> TwitchAuth:
response = await self.client.post(
"/oauth2/token",
data={
"client_id": Settings.get().twitch_client_id,
"client_secret": Settings.get().twitch_client_secret.get_secret_value(),
"grant_type": "authorization_code",
"code": code,
"redirect_uri": f"{Settings.get().server_hostname}o/code",
},
headers={"Accept": "application/json"},
)
if auto_refresh and response.status_code == 401:
return await self.refresh_token(response.json()["refresh_token"])
response.raise_for_status()
return TwitchAuth(**response.json())
async def refresh_token(self, refresh_token: str) -> TwitchAuth:
response = await self.client.post(
"/oauth2/token",
data={
"client_id": Settings.get().twitch_client_id,
"client_secret": Settings.get().twitch_client_secret.get_secret_value(),
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
headers={"Accept": "application/json"},
)
response.raise_for_status()
return TwitchAuth(**response.json())
async def validate_token(self, access_token: str) -> str:
response = await self.client.get(
"/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"}
)
response.raise_for_status()
user_data = response.json()
if user_data.get("status"):
raise HTTPException(status_code=401, detail="Unauthorized")
if (user := user_data["login"]) not in self.s.allowed_users:
raise HTTPException(status_code=403, detail="Forbidden")
return user

134
src/huesoporro/infra/db.py Normal file
View file

@ -0,0 +1,134 @@
import datetime
from contextlib import asynccontextmanager
import aiosqlite
from pydantic import BaseModel, Field
from src.huesoporro.models import ChatbotSettings, User
from src.huesoporro.settings import Settings
from loguru import logger
class Database(BaseModel):
s: Settings = Field(default_factory=Settings.get)
@asynccontextmanager
async def get_client(self, auto_commit=True):
logger.info(f"Opening database connection: {self.s.db_filepath}")
async with aiosqlite.connect(self.s.db_filepath) as db:
yield db
if auto_commit:
await db.commit()
@staticmethod
def get_now() -> float:
return datetime.datetime.now(datetime.UTC).timestamp()
async def save_user(self, user: User, auto_commit=True):
async with self.get_client(auto_commit=auto_commit) as db:
async with db.execute(
"SELECT * FROM users WHERE user = ?", (user.user,)
) as cursor:
result = await cursor.fetchone()
if result:
await db.execute(
"UPDATE users SET access_token = ?, refresh_token = ?, expires_at = ?, last_updated_at = ? WHERE user = ?",
(
user.twitch_auth.access_token,
user.twitch_auth.refresh_token,
user.expires_at,
self.get_now(),
user.user,
),
)
return
await db.execute(
"INSERT INTO users (user, access_token, refresh_token, expires_at, last_updated_at) VALUES (?,?,?,?,?)",
(
user.user,
user.twitch_auth.access_token,
user.twitch_auth.refresh_token,
user.expires_at,
self.get_now(),
),
)
async def save_quote(self, channel: str, quote: str, author: str, auto_commit=True):
async with self.get_client(auto_commit=auto_commit) as db:
await db.execute(
"INSERT INTO quotes (channel, quote, author) VALUES (?,?,?)",
(channel, quote, author),
)
async def save_chatbot_settings(
self, user: User, chatbot_settings: ChatbotSettings, auto_commit: bool = True
):
async with self.get_client(auto_commit=auto_commit) as db:
current_settings = await self.get_chatbot_settings(user)
if current_settings:
await db.execute(
"""UPDATE settings SET
automatic_generation_timer = ?,
automatic_quote_timer = ?,
mods = ?,
last_updated_at = ?
WHERE user_id = ?
""",
(
chatbot_settings.automatic_generation_timer,
chatbot_settings.automatic_quote_timer,
chatbot_settings.mods_as_string,
self.get_now(),
user.user,
),
)
return
await db.execute(
"""INSERT INTO settings (
user_id,
automatic_generation_timer,
automatic_quote_timer,
mods,
created_at,
last_updated_at
) VALUES(?,?,?,?,?,?)
""",
(
user.user,
chatbot_settings.automatic_generation_timer,
chatbot_settings.automatic_quote_timer,
chatbot_settings.mods_as_string,
self.get_now(),
self.get_now(),
),
)
async def get_chatbot_settings(self, user: User) -> ChatbotSettings | None:
async with self.get_client() as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM settings WHERE user_id = ?", (user.user,)
) as cursor:
result = await cursor.fetchone()
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_random_quote(self, channel_name: str):
async with self.get_client() as db:
async with db.execute(
"SELECT quote, author FROM quotes WHERE channel = ? ORDER BY RANDOM() LIMIT 1",
(channel_name,),
) as cursor:
result = await cursor.fetchone()
return result

View file

@ -1,543 +0,0 @@
import string
import time
from enum import StrEnum
from loguru import logger
from nltk.tokenize import sent_tokenize
from TwitchWebsocket import Message, TwitchWebsocket
from src.huesoporro.libs.db import Database
from src.huesoporro.libs.settings import Settings
from src.huesoporro.libs.timer import LoopingTimer
from src.huesoporro.libs.tokenizer import detokenize, tokenize
class Commands(StrEnum):
SET_COOLDOWN = "!setcd"
GENERATE = "!g"
BLACKLIST = "!blacklist"
GENERATE_HELP = "!ghelp"
QUOTE = "!q"
QUOTE_ADD = "!qadd"
class MarkovChain:
end_tag = "<END>"
def __init__(self, settings: Settings | None = None):
self.s = settings or Settings.read()
self.prev_message_t = 0.0
self._enabled = True
self.db = Database(self.s.channel_name)
if self.s.help_message_timer > 0:
if self.s.help_message_timer < 300: # noqa: PLR2004
raise ValueError(
'Value for "HelpMessageTimer" in must be at least 300 seconds, ' # noqa: EM101
"or a negative number for no help messages.",
)
t = LoopingTimer(self.s.help_message_timer, self._command_help)
t.start()
# Set up daemon Timer to send automatic generation messages
if self.s.automatic_generation_timer > 0:
if self.s.automatic_generation_timer < 30: # noqa: PLR2004
raise ValueError(
'Value for "Automatic_generation_message" must be at least 30 seconds, or a negative number for no ' # noqa: EM101
"automatic generations.",
)
logger.info(
f"Automatic generation enabled, will send messages every {self.s.automatic_generation_timer} seconds"
)
t = LoopingTimer(
self.s.automatic_generation_timer,
self._command_automatic_generation,
)
t.start()
self.ws = TwitchWebsocket(
host=self.s.host,
port=self.s.port,
chan=self.s.channel_name,
nick=self.s.nickname,
auth=self.s.authentication,
callback=self.message_handler,
capability=["commands", "tags"],
live=True,
)
def run_bot(self):
self.ws.start_nonblocking()
def stop_bot(self):
self.ws.leave_channel(self.s.channel_name)
self.ws.stop()
logger.info("Stopped bot")
def _command_help(self) -> None:
"""Send a Help message to the connected chat, as long as the bot wasn't disabled."""
if self._enabled:
logger.info("Help message sent.")
try:
self.ws.send_message(
"Learn how this bot generates sentences here: https://github.com/CubieDev/TwitchMarkovChain#how-it-works",
)
except OSError as error:
logger.warning(
f"[OSError: {error}] upon sending help message. Ignoring.",
)
def _command_set_cooldown(self, username: str, split_message: list[str]):
if len(split_message) == 2: # noqa: PLR2004
try:
cooldown = int(split_message[1])
except ValueError:
self.ws.send_whisper(
username,
"The parameter must be an integer amount, eg: !setcd 30",
)
return
self.s.cooldown = cooldown
self.s.write()
self.ws.send_whisper(
username,
f"The !generate cooldown has been set to {cooldown} seconds.",
)
def _command_blacklist(self, username: str, split_message: list[str]):
if len(split_message) == 2: # noqa: PLR2004
try:
blacklisted_username = split_message[1]
except ValueError:
self.ws.send_whisper(
username,
"The parameter must be a username, eg: !blacklist ibai",
)
return
self.s.denied_users.append(blacklisted_username)
self.s.write()
def _command_generate(self, username: str, message: str):
cur_time = time.time()
if self.prev_message_t + self.s.cooldown >= cur_time:
if not self.db.check_whisper_ignore(username):
self.send_whisper(
username,
f"Cooldown hit: {self.prev_message_t + self.s.cooldown - cur_time:0.2f} out of {self.s.cooldown:.0f}s remaining. !nopm to stop these cooldown pm's.",
)
logger.info(
f"Cooldown hit with {self.prev_message_t + self.s.cooldown - cur_time:0.2f}s remaining.",
)
params = tokenize(message)[2:] if self.s.allow_generate_params else None
# Generate an actual sentence
sentence, success = self.generate(params)
if success:
# Reset cooldown if a message was actually generated
self.prev_message_t = time.time()
logger.info(sentence)
self.ws.send_message(sentence)
self.store_sentence(message)
def _command_automatic_generation(self) -> None:
"""Send an automatic generation message to the connected chat.
As long as the bot wasn't disabled, just like if someone typed "!g" in chat.
"""
if self._enabled:
logger.debug("Automatically generating message")
sentence, success = self.generate()
if success:
logger.info(
f"Created '{sentence}'. Cooling down for {self.s.automatic_generation_timer} seconds before regenerating",
)
try:
self.ws.send_message(sentence)
except OSError as error:
logger.warning(
f"[OSError: {error}] upon sending automatic generation message. Ignoring.",
)
else:
logger.info(
"Attempted to output automatic generation message, but there is not enough learned information yet.",
)
def _command_quote(self):
"""Retrieve a random quote from the `quotes` table and format it as
> «<quote>» - <author>
"""
data = self.db.execute(
"SELECT quote, author FROM quotes ORDER BY RANDOM() LIMIT 1;", fetch=True
)
if data:
data = data[0]
quote, author = data[0], data[1]
self.ws.send_message(f"«{quote}» - {author}")
def _command_add_quote(self, message: str):
"""Add a quote to the quotes table. The message should follow the format:
!qadd quote author
The last word will be parsed as the author and anything in between !qadd and the author will be considered
as the quote itself
"""
# Split the message into quote and author
parts = message.split()
author = parts[-1]
quote = " ".join(parts[1:-1])
data = self.db.execute(
"SELECT 1 FROM quotes WHERE quote = ?", (quote,), fetch=True
)
if data:
self.ws.send_message(f"Quote «{quote}» was already added.")
return
self.db.execute(
"INSERT INTO quotes (quote, author) VALUES (?, ?)",
(quote, author), # type: ignore[arg-type]
)
self.ws.send_message(f"Quote «{quote}» by {author} added.")
def store_sentence(self, message: str):
logger.info(f"Processing {message} in order to store it")
stripped_message = message.strip()
try:
sentences = sent_tokenize(stripped_message)
except LookupError:
logger.debug("Downloading required punkt resource...")
import nltk
nltk.download("punkt")
logger.debug("Downloaded required punkt resource.")
sentences = sent_tokenize(stripped_message)
for sentence in sentences:
words = tokenize(sentence)
# Double spaces will lead to invalid rules. We remove empty words here
if "" in words:
words = [word for word in words if word]
# If the sentence is too short, ignore it and move on to the next.
if len(words) <= self.s.key_length:
continue
# Add a new starting point for a sentence to the <START>
words = [words[x] for x in range(self.s.key_length)]
logger.debug(f"Adding {words} to start queue")
self.db.add_start_queue(words)
# Create Key variable which will be used as a key in the Dictionary for the grammar
key: list[str] = []
for word in words:
# Set up key for first use
if len(key) < self.s.key_length:
key.append(word)
continue
logger.debug(f"Adding {key}[{word}] to rule queue")
self.db.add_rule_queue([*key, word])
# Remove the first word, and add the current word,
# so that the key is correct for the next word.
key.pop(0)
key.append(word)
logger.debug(f"Adding {key} to rule queue")
# Add <END> at the end of the sentence
self.db.add_rule_queue([*key, self.end_tag])
def message_handler(self, message: Message): # noqa: C901, PLR0911, PLR0912
try:
"""
tts_message = {
"badge-info": "subscriber/4",
"badges": "vip/1,subscriber/3,sub-gifter/5",
"color": "#F79AC6",
"custom-reward-id": "8c454446-73b0-480f-946e-d6b5f5c5e331",
"display-name": "robosap1ens__",
"emotes": "",
"first-msg": "0",
"flags": "",
"id": "6cbd37eb-49ae-41f5-b073-345275c91a07",
"mod": "0",
"returning-chatter": "0",
"room-id": "600944302",
"subscriber": "1",
"tmi-sent-ts": "1733252657689",
"turbo": "0",
"user-id": "713968248",
"user-type": "",
"vip": "1",
}
"""
if not message.user or message.user in self.s.denied_users:
logger.debug(f"User {message.user} can't send messages")
return
msgs = message.message.split()
if not msgs:
logger.debug("Message is empty")
return
if "bits" in message.tags:
return
if "emotes" in message.tags:
# Replace modified emotes with normal versions,
# as the bot will never have the modified emotes unlocked at the time.
for modifier in self.extract_modifiers(message.tags["emotes"]):
message.message = message.message.replace(modifier, "")
logger.debug(f"Received {msgs[0]} command from {message.user}")
match msgs[0]:
case Commands.GENERATE_HELP:
logger.debug("Executing _command_help()")
self._command_help()
case Commands.SET_COOLDOWN:
if self.is_mod(message.user, message.channel):
logger.debug(
f"User {message.user} is mod, executing _command_set_cooldown()",
)
self._command_set_cooldown(
split_message=msgs,
username=message.user,
)
case Commands.BLACKLIST:
if self.is_mod(message.user, message.channel):
logger.debug(
f"User {message.user} is a mod, executing _command_blacklist()",
)
self._command_blacklist(
split_message=msgs,
username=message.user,
)
case Commands.GENERATE:
if not self._enabled:
logger.info("Bot not enabled, skipping")
return
if message.user not in self.s.denied_users:
logger.info(
f"User {message.user} allowed to generate, executing _command_generate()",
)
self._command_generate(
message=message.message,
username=message.user,
)
case Commands.QUOTE:
if not self._enabled:
logger.info("Bot not enabled, skipping")
return
if message.user not in self.s.denied_users:
logger.info(
f"User {message.user} allowed to generate, executing _command_quote()",
)
self._command_quote()
case Commands.QUOTE_ADD:
if self.is_mod(message.user, message.channel):
logger.info(
f"User {message.user} allowed to create quote, executing _command_quote()",
)
self._command_add_quote(message.message)
return
self.ws.send_message(
f"@{message.user} you're not in the modlist, you can't add quotes"
)
case _:
logger.debug(
f"Not a command: {msgs[0]}. Storing into db as a plain message",
)
if message.type == "366":
logger.info(f"Successfully joined channel: #{message.channel}")
return
self.store_sentence(message.message)
except Exception: # noqa: BLE001
logger.exception(f"Could not process message {message}")
def generate(self, params: list[str] | None = None) -> tuple[str, bool]: # noqa: C901, PLR0912
"""Given an input sentence, generate the remainder of the sentence using the learned data.
Args:
params (list[str]): A list of words to use as an input to use as the start of generating.
Returns:
tuple[str, bool]: A tuple of a sentence as the first value, and a boolean indicating
whether the generation succeeded as the second value.
"""
params = params or []
# List of sentences that will be generated. In some cases, multiple sentences will be generated,
# e.g. when the first sentence has less words than self.min_sentence_length.
sentences: list[list | list[str]] = [[]]
# Check for commands or recursion, eg: !generate !generate
if len(params) > 0 and self.is_command(params[0]):
return "You can't make me do commands, you madman!", False
# Get the starting key and starting sentence.
# If there is more than 1 param, get the last 2 as the key.
# Note that self.s.key_length is fixed to 2 in this implementation
if len(params) > 1:
key = params[-self.s.key_length :]
# Copy the entire params for the sentence
sentences[0] = params.copy()
elif len(params) == 1:
# First we try to find if this word was once used as the first word in a sentence:
key = self.db.get_next_single_start(params[0]) # type: ignore[assignment]
if key is None:
# If this failed, we try to find the next word in the grammar as a whole
key = self.db.get_next_single_initial(0, params[0])
if key is None:
# Return a message that this word hasn't been learned yet
return f'I haven\'t extracted "{params[0]}" from chat yet.', False
# Copy this for the sentence
sentences[0] = key.copy()
else: # if there are no params
# Get starting key
key = self.db.get_start()
if key:
# Copy this for the sentence
sentences[0] = key.copy()
else:
# If nothing's ever been said
return "There is not enough learned information yet.", False
# Counter to prevent infinite loops (i.e. constantly generating <END> while below the
# minimum number of words to generate)
i = 0
while (
self.get_sentence_length(sentences) < self.s.max_sentence_length
and i < self.s.max_sentence_length * 2
):
# Use key to get next word
if i == 0:
# Prevent fetching <END> on the first word
word = self.db.get_next_initial(i, key)
else:
word = self.db.get_next(i, key)
i += 1
if word == "<END>" or word is None:
# Break, unless we are before the min_sentence_length
if i < self.s.min_sentence_length:
key = self.db.get_start()
# Ensure that the key can be generated. Otherwise, we still stop.
if key:
# Start a new sentence
sentences.append([])
for entry in key:
sentences[-1].append(entry)
continue
break
# Otherwise add the word
sentences[-1].append(word)
# Shift the key so on the next iteration it gets the next item
key.pop(0)
key.append(word)
# If there were params, but the sentence resulting is identical to the params
# Then the params did not result in an actual sentence
# If so, restart without params
if len(params) > 0 and params == sentences[0]:
return "I haven't learned what to do with \"" + detokenize(
params[-self.s.key_length :],
) + '" yet.', False
return self.s.sentence_separator.join(
detokenize(sentence) for sentence in sentences
), True
@staticmethod
def get_sentence_length(sentences: list[list[str]]) -> int:
"""Given a list of tokens representing a sentence, return the number of words in there.
Args:
sentences (List[List[str]]): List of lists of tokens that make up a sentence,
where a token is a word or punctuation. For example:
[['Hello', ',', 'you', "'re", 'Tom', '!'], ['Yes', ',', 'I', 'am', '.']]
This would return 6.
Returns:
int: The number of words in the sentence.
"""
count = 0
for sentence in sentences:
for token in sentence:
if token not in string.punctuation and token[0] != "'":
count += 1
return count
@staticmethod
def extract_modifiers(emotes: str) -> list[str]:
"""Extract emote modifiers from emotes such as the horizontal flip.
Args:
emotes (str): String containing all emotes used in the message.
Returns:
list[str]: List of strings that show modifiers, such as "_HZ" for horizontal flip.
"""
output = []
try:
while emotes:
u_index = emotes.index("_")
c_index = emotes.index(":", u_index)
output.append(emotes[u_index:c_index])
emotes = emotes[c_index:]
except ValueError:
pass
return output
def send_whisper(self, user: str, message: str) -> None:
"""Optionally send a whisper, only if "WhisperCooldown" is True.
Args:
user (str): The user to potentially whisper.
message (str): The message to potentially whisper
"""
if self.s.whisper_cooldown:
self.ws.send_whisper(user, message)
@staticmethod
def is_command(message: str) -> bool:
"""True if the message is any command, except /me.
Is used to avoid learning and generating commands.
Args:
message (str): The message to check.
Returns:
bool: True if the message is any potential command (starts with a '!', '/' or '.')
except /me.
"""
return message in list(Commands)
def is_mod(self, username: str, channel: str) -> bool:
"""True if the user is a moderator.
Args:
username (str): The name of the user to check
channel (str): The name of the channel
Returns:
bool: True if the user is a moderator.
"""
return username in self.s.mods or username == channel
if __name__ == "__main__":
MarkovChain()

View file

@ -1,118 +0,0 @@
import json
from pathlib import Path
from typing import Literal
import platformdirs
from loguru import logger
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
host: str = Field("irc.chat.twitch.tv", alias="Host", serialization_alias="Host")
port: int = Field(6667, alias="Port", serialization_alias="Port")
channel: str = Field(..., alias="Channel", serialization_alias="Channel")
nickname: str = Field(..., alias="Nickname", serialization_alias="Nickname")
authentication: str = Field(
...,
alias="Authentication",
serialization_alias="Authentication",
)
denied_users: list[str] = Field(
[
"StreamElements",
"Nightbot",
"Moobot",
"Marbiebot",
],
alias="DeniedUsers",
serialization_alias="DeniedUsers",
)
banned_words: list[str] = Field(
default_factory=list,
alias="BannedWords",
serialization_alias="BannedWords",
)
mods: list[str] = Field(
default_factory=list,
alias="Mods",
serialization_alias="Mods",
)
cooldown: int = Field(210, alias="Cooldown", serialization_alias="Cooldown")
key_length: int = Field(2, alias="KeyLength", serialization_alias="KeyLength")
max_sentence_length: int = Field(
25,
alias="MaxSentenceWordAmount",
serialization_alias="MaxSentenceWordAmount",
)
min_sentence_length: int = Field(
-1,
alias="MinSentenceWordAmount",
serialization_alias="MinSentenceWordAmount",
)
help_message_timer: int = Field(
60 * 60 * 5,
alias="HelpMessageTimer",
serialization_alias="HelpMessageTimer",
)
automatic_generation_timer: int = Field(
-1,
alias="AutomaticGenerationTimer",
serialization_alias="AutomaticGenerationTimer",
)
whisper_cooldown: bool = Field(
True,
alias="WhisperCooldown",
serialization_alias="WhisperCooldown",
)
enable_generate_command: bool = Field(
True,
alias="EnableGenerateCommand",
serialization_alias="EnableGenerateCommand",
)
sentence_separator: str = Field(
" - ",
alias="SentenceSeparator",
serialization_alias="SentenceSeparator",
)
allow_generate_params: bool = Field(
True,
alias="AllowGenerateParams",
serialization_alias="AllowGenerateParams",
)
log_level: Literal[
"CRITICAL",
"ERROR",
"WARNING",
"INFO",
"DEBUG",
"TRACE",
] = Field("DEBUG", alias="LogLevel")
model_config = SettingsConfigDict(extra="ignore")
@property
def channel_name(self):
return self.channel.replace("#", "").lower()
@classmethod
def read(cls, filepath: Path | None = None) -> "Settings":
if not filepath:
filepath = (
platformdirs.user_config_path("markovbot_gui", ensure_exists=True)
/ "settings.json"
)
with filepath.open("r") as f:
data = json.load(f)
return Settings(**data)
def write(self, filepath: Path | None = None):
if not filepath:
filepath = (
platformdirs.user_config_path("markovbot_gui", ensure_exists=True)
/ "settings.json"
)
with filepath.open("w") as f:
logger.info(f"Writing current settings to {filepath}")
json.dump(self.model_dump(by_alias=True), f, indent=4)

View file

@ -1,248 +1,7 @@
import json
import secrets
from json import JSONDecodeError
import httpx
import uvicorn import uvicorn
from litestar import Litestar, MediaType, Request, Response, WebSocket, get
from litestar.connection import ASGIConnection
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.datastructures.state import State
from litestar.di import Provide
from litestar.exceptions import HTTPException
from litestar.handlers import BaseRouteHandler, WebsocketListener
from litestar.response import Redirect, Template
from litestar.static_files import StaticFilesConfig
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
from litestar.template import TemplateConfig
from loguru import logger
from src.huesoporro.chatbot import ChatbotManager from src.huesoporro.api.main import create_app
from src.huesoporro.settings import Settings from src.huesoporro.settings import Settings
from src.huesoporro.tts import TTSManager
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
async def _authenticate(access_token: str):
s = Settings.get()
client = httpx.AsyncClient(
base_url="https://id.twitch.tv",
)
resp = await client.get(
"/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"}
)
user_data = resp.json()
if user_data.get("status"):
raise HTTPException(status_code=401, detail="Unauthorized")
if (user := user_data["login"]) not in s.allowed_users:
raise HTTPException(status_code=403, detail="Forbidden")
return user
async def authenticate(
connection: ASGIConnection, route_handler: BaseRouteHandler
) -> None:
"""Extract cookie from connection and try to authenticate"""
try:
login_data = json.loads(connection.cookies.get("twitchLoginData"))
except (JSONDecodeError, TypeError) as exc:
logger.warning(f"Error parsing twitch login data: {exc}")
raise HTTPException(status_code=401, detail="Unauthorized") from exc
access_token = login_data.get("access_token")
if not login_data or not access_token:
raise HTTPException(status_code=401, detail="Unauthorized")
user = await _authenticate(access_token)
connection.state["user"] = user
connection.state["access_token"] = access_token
class WebsocketHandler(WebsocketListener):
path = "/ws"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tts_manager = TTSManager()
self.chatbot_manager = ChatbotManager()
self.user = None
self.access_token = None
async def on_accept(self, socket: WebSocket, state: State) -> None:
"""If the authentication is correct, add the manager's clients list"""
cookies = socket.cookies.get("twitchLoginData")
try:
access_token = json.loads(cookies).get("access_token")
except (JSONDecodeError, TypeError) as exc:
logger.warning(f"Error parsing twitch login data {exc}")
return
if not access_token:
return
user = await _authenticate(access_token)
self.user = user
self.access_token = access_token
self.chatbot_manager.clients.add(socket)
self.tts_manager.clients.append(socket)
logger.info(
f"Connection accepted from {socket.client.host}:{socket.client.port}" # type: ignore[union-attr]
)
async def on_disconnect(self, socket: WebSocket) -> None:
# Remove client from the list
if socket in self.tts_manager.clients:
self.tts_manager.clients.remove(socket)
self.chatbot_manager.clients.remove(socket)
logger.info(f"Connection closed by {socket.client.host}:{socket.client.port}") # type: ignore[union-attr]
async def on_receive(self, data: str, state: State) -> None:
message = WebsocketMessage(**json.loads(data))
logger.info(f"Received {message.command.value} command")
match message.command:
case WebsocketCommands.TTS_SEND:
await self.tts_manager.add_to_queue(**message.data)
case WebsocketCommands.CHATBOT_START:
self.chatbot_manager.start_bot(
**message.data
| {
"nickname": self.user,
"authentication": f"oauth:{self.access_token}",
},
)
case WebsocketCommands.CHATBOT_STOP:
self.chatbot_manager.stop_bot()
@get(
"/tts",
media_type=MediaType.HTML,
guards=[authenticate],
)
async def get_tts_overlay() -> Template:
return Template(template_name="tts.html")
@get(
"/tts/permalink",
media_type=MediaType.HTML,
)
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
await _authenticate(access_token)
return Template(
template_name="tts.html",
)
@get(
"/",
media_type=MediaType.HTML,
guards=[authenticate],
)
async def get_index() -> Template:
return Template(
template_name="index.html",
)
@get("/login", media_type=MediaType.HTML, dependencies={"s": Provide(Settings.get)})
async def login(s: Settings) -> Template:
scopes = "+".join(s.twitch_scopes)
return Template(
"login.html",
context={
"twitch_login_url": "https://id.twitch.tv/oauth2/authorize?response_type=token"
f"&client_id={s.twitch_client_id}"
f"&redirect_uri={s.server_hostname}login"
f"&scope={scopes}"
f"&state={secrets.token_urlsafe(32)}"
},
)
@get("/healthz")
def get_health() -> dict:
return {"status": "ok"}
@get("/lefunny")
def get_lefunny() -> Template:
return Template(
template_name="lefunny.html",
context={"sentences": [{"sentence": "Hola huesoperro", "id": 1}]},
)
def exception_handler(_: Request, exc: Exception) -> Response:
status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
detail = getattr(exc, "detail", "")
if isinstance(exc, HTTPException) and (exc.status_code in [401, 403]):
logger.warning("User could not authenticate. Redirecting to /login page")
return Redirect("/login")
return Response(
media_type=MediaType.TEXT,
content=detail,
status_code=status_code,
)
async def after_exception_handler(exc: Exception, scope: "Scope") -> None:
"""Hook function that will be invoked after each exception."""
state = scope["app"].state
if not hasattr(state, "error_count"):
state.error_count = 1
else:
state.error_count += 1
logger.error(
f"an exception of type {type(exc).__name__} has occurred for requested path {scope['path']} and the application error count is {state.error_count}.",
)
def create_app():
return Litestar(
route_handlers=[
get_health,
login,
get_index,
get_tts_overlay,
get_tts_permalink,
get_lefunny,
WebsocketHandler,
],
static_files_config=(
StaticFilesConfig(
path="/tts_files",
directories=[Settings.get().tts_cache_path],
),
StaticFilesConfig(
path="static",
directories=[Settings.get().static_files_path],
),
),
template_config=TemplateConfig(
directory=Settings.get().templates_files_path,
engine=JinjaTemplateEngine,
),
exception_handlers={HTTPException: exception_handler},
after_exception=[after_exception_handler],
)
if __name__ == "__main__": if __name__ == "__main__":
settings = Settings.get() settings = Settings.get()

50
src/huesoporro/models.py Normal file
View file

@ -0,0 +1,50 @@
from typing import Self
import jwt
from pydantic import BaseModel, field_validator
from src.huesoporro.settings import Settings
class TwitchAuth(BaseModel):
access_token: str
refresh_token: str
class User(BaseModel):
user: str
expires_at: float
twitch_auth: TwitchAuth
def encode(self, settings: Settings | None = None) -> str:
s = settings or Settings.get()
return jwt.encode(
self.model_dump(),
key=s.jwt_secret.get_secret_value(),
algorithm="HS256",
)
@classmethod
def decode(cls, token: str, settings: Settings | None = None) -> Self:
s = settings or Settings.get()
decoded = jwt.decode(
token, key=s.jwt_secret.get_secret_value(), algorithms=["HS256"]
)
return cls(**decoded)
class ChatbotSettings(BaseModel):
automatic_generation_timer: int = 300
automatic_quote_timer: int = 500
mods: list[str] | None = None
@property
def mods_as_string(self):
return ",".join(self.mods)
@field_validator("mods", mode="before")
@classmethod
def format_mods_from_string(cls, v):
if isinstance(v, str):
return v.split(",")
return v

View file

@ -1,7 +1,7 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import Field, HttpUrl, field_validator from pydantic import Field, HttpUrl, SecretStr, field_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -21,6 +21,8 @@ class Settings(BaseSettings):
default_factory=lambda: Path(__file__).parent / "huesoporro.db" default_factory=lambda: Path(__file__).parent / "huesoporro.db"
) )
twitch_client_id: str twitch_client_id: str
twitch_client_secret: SecretStr
jwt_secret: SecretStr
twitch_scopes: list[str] = Field( twitch_scopes: list[str] = Field(
default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"] default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"]
) )

View file

View file

@ -0,0 +1,26 @@
import datetime
from pydantic import BaseModel
from src.huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.db import Database
from src.huesoporro.models import User
class CodeAuthenticatorSvc(BaseModel):
db: Database
authenticator: TwitchAuthenticator
@staticmethod
def get_four_hours_from_now() -> float:
now = datetime.datetime.now(datetime.UTC)
four_hours_later = now + datetime.timedelta(hours=4)
return four_hours_later.timestamp()
async def run(self, code: str) -> User:
auth = await self.authenticator.get_token(code)
username = await self.authenticator.validate_token(auth.access_token)
expires_at = self.get_four_hours_from_now()
user = User(user=username, expires_at=expires_at, twitch_auth=auth)
await self.db.save_user(user)
return user

View file

@ -0,0 +1,158 @@
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
class SentenceGeneratorSvc(BaseModel):
db: MarkovDB
min_sentence_length: int = 2
key_length: int = 2
max_sentence_length: int = 25
sentence_separator: str = " "
model_config = ConfigDict(arbitrary_types_allowed=True)
def is_mod(self, username: str, channel: str) -> bool:
"""True if the user is a moderator.
Args:
username (str): The name of the user to check
channel (str): The name of the channel
Returns:
bool: True if the user is a moderator.
"""
return username in self.s.mods or username == channel
@staticmethod
def get_sentence_length(sentences: list[list[str]]) -> int:
"""Given a list of tokens representing a sentence, return the number of words in there.
Args:
sentences (List[List[str]]): List of lists of tokens that make up a sentence,
where a token is a word or punctuation. For example:
[['Hello', ',', 'you', "'re", 'Tom', '!'], ['Yes', ',', 'I', 'am', '.']]
This would return 6.
Returns:
int: The number of words in the sentence.
"""
count = 0
for sentence in sentences:
for token in sentence:
if token not in string.punctuation and token[0] != "'":
count += 1
return count
def generate(self, params: list[str] | None = None) -> tuple[str, bool]: # noqa: C901, PLR0912
"""Given an input sentence, generate the remainder of the sentence using the learned data.
Args:
params (list[str]): A list of words to use as an input to use as the start of generating.
Returns:
tuple[str, bool]: A tuple of a sentence as the first value, and a boolean indicating
whether the generation succeeded as the second value.
"""
params = params or []
# List of sentences that will be generated. In some cases, multiple sentences will be generated,
# e.g. when the first sentence has fewer words than self.min_sentence_length.
sentences: list[list | list[str]] = [[]]
# Check for commands or recursion, eg: !generate !generate
if len(params) > 0:
return "You can't make me do commands, you madman!", False
# Get the starting key and starting sentence.
# If there is more than 1 param, get the last 2 as the key.
# Note that self.key_length is fixed to 2 in this implementation
if len(params) > 1:
key = params[-self.key_length :]
# Copy the entire params for the sentence
sentences[0] = params.copy()
elif len(params) == 1:
# First we try to find if this word was once used as the first word in a sentence:
key = self.db.get_next_single_start(params[0]) # type: ignore[assignment]
if key is None:
# If this failed, we try to find the next word in the grammar as a whole
key = self.db.get_next_single_initial(0, params[0])
if key is None:
# Return a message that this word hasn't been learned yet
return f'I haven\'t extracted "{params[0]}" from chat yet.', False
# Copy this for the sentence
sentences[0] = key.copy()
else: # if there are no params
# Get starting key
key = self.db.get_start()
if key:
# Copy this for the sentence
sentences[0] = key.copy()
else:
# If nothing's ever been said
return "There is not enough learned information yet.", False
# Counter to prevent infinite loops (i.e. constantly generating <END> while below the
# minimum number of words to generate)
i = 0
while (
self.get_sentence_length(sentences) < self.max_sentence_length
and i < self.max_sentence_length * 2
):
# Use key to get next word
if i == 0:
# Prevent fetching <END> on the first word
word = self.db.get_next_initial(i, key)
else:
word = self.db.get_next(i, key)
i += 1
if word == "<END>" or word is None:
# Break, unless we are before the min_sentence_length
if i < self.min_sentence_length:
key = self.db.get_start()
# Ensure that the key can be generated. Otherwise, we still stop.
if key:
# Start a new sentence
sentences.append([])
for entry in key:
sentences[-1].append(entry)
continue
break
# Otherwise add the word
sentences[-1].append(word)
# Shift the key so on the next iteration it gets the next item
key.pop(0)
key.append(word)
# If there were params, but the sentence resulting is identical to the params
# Then the params did not result in an actual sentence
# If so, restart without params
if len(params) > 0 and params == sentences[0]:
return "I haven't learned what to do with \"" + detokenize(
params[-self.key_length :],
) + '" yet.', False
return self.sentence_separator.join(
detokenize(sentence) for sentence in sentences
), True
async def run(
self,
sentence: str | None = None,
) -> str|None:
if sentence:
sentence = tokenize(sentence)
logger.info(f"Generating sentence from {sentence}")
sentence, success = self.generate(sentence)
logger.info(f"Generated sentence: {sentence}")
if success:
return sentence

View file

@ -0,0 +1,11 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, User
class ChatbotSettingsGetterSvc(BaseModel):
db: Database
async def run(self, user: User) -> ChatbotSettings | None:
return await self.db.get_chatbot_settings(user=user)

View file

@ -0,0 +1,10 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
class RandomQuoteGetterSvc(BaseModel):
db: Database
async def run(self, channel_name: str) -> tuple[str, str]:
return await self.db.get_random_quote(channel_name=channel_name)

View file

@ -0,0 +1,10 @@
import random
from pydantic import BaseModel, Field
class HelloGeneratorSvc(BaseModel):
hellos: list[str] = Field(default_factory=lambda: ["Hola", "Ayo", "Hi", "Bon día"])
def run(self, username: str):
return f"{random.choice(self.hellos)} {username}"

View file

@ -0,0 +1,12 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
from src.huesoporro.models import User
class IsModSvc(BaseModel):
db: Database
async def run(self, user: User, username: str) -> bool:
chatbot_settings = await self.db.get_chatbot_settings(user=user)
return username in chatbot_settings.mods

View file

@ -0,0 +1,27 @@
import datetime
from pydantic import BaseModel
from src.huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.db import Database
from src.huesoporro.models import User
class RefreshTokenAuthenticator(BaseModel):
db: Database
authenticator: TwitchAuthenticator
@staticmethod
def get_four_hours_from_now() -> float:
now = datetime.datetime.now(datetime.UTC)
four_hours_later = now + datetime.timedelta(hours=4)
return four_hours_later.timestamp()
async def run(self, refresh_token: str) -> User:
auth = await self.authenticator.refresh_token(refresh_token)
username = await self.authenticator.validate_token(auth.access_token)
expires_at = self.get_four_hours_from_now()
user = User(user=username, expires_at=expires_at, twitch_auth=auth)
await self.db.save_user(user)
return user

View file

@ -0,0 +1,63 @@
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
class SentenceStorerSvc(BaseModel):
db: MarkovDB
key_length: int = 2
end_tag: str = "<END>"
model_config = ConfigDict(arbitrary_types_allowed=True)
def store_sentence(self, message: str):
logger.info(f"Processing {message} in order to store it")
stripped_message = message.strip()
try:
sentences = sent_tokenize(stripped_message)
except LookupError:
logger.debug("Downloading required punkt resource...")
import nltk
nltk.download("punkt")
logger.debug("Downloaded required punkt resource.")
sentences = sent_tokenize(stripped_message)
for sentence in sentences:
words = tokenize(sentence)
# Double spaces will lead to invalid rules. We remove empty words here
if "" in words:
words = [word for word in words if word]
# If the sentence is too short, ignore it and move on to the next.
if len(words) <= self.key_length:
continue
# Add a new starting point for a sentence to the <START>
words = [words[x] for x in range(self.key_length)]
logger.debug(f"Adding {words} to start queue")
self.db.add_start_queue(words)
# Create Key variable which will be used as a key in the Dictionary for the grammar
key: list[str] = []
for word in words:
# Set up key for first use
if len(key) < self.key_length:
key.append(word)
continue
logger.debug(f"Adding {key}[{word}] to rule queue")
self.db.add_rule_queue([*key, word])
# Remove the first word, and add the current word,
# so that the key is correct for the next word.
key.pop(0)
key.append(word)
logger.debug(f"Adding {key} to rule queue")
# Add <END> at the end of the sentence
self.db.add_rule_queue([*key, self.end_tag])
async def run(self, sentence: str):
return self.store_sentence(sentence)

View file

@ -0,0 +1,10 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
class QuoteStorerSvc(BaseModel):
db: Database
async def run(self, channel: str, quote: str, author: str):
await self.db.save_quote(channel, quote, author)

View file

@ -0,0 +1,15 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, User
class ChatbotSettingsStorerSvc(BaseModel):
db: Database
async def run(
self, user: User, bot_settings: ChatbotSettings
) -> dict[str, str | int | None] | None:
return await self.db.save_chatbot_settings(
user=user, chatbot_settings=bot_settings
)

View file

@ -3,6 +3,7 @@
<head> <head>
<link rel="stylesheet" href="/static/css/pico/pico.classless.min.css"> <link rel="stylesheet" href="/static/css/pico/pico.classless.min.css">
<link rel="stylesheet" href="/static/css/pico/pico.colors.min.css">
<link rel="icon" <link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>"> href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
<meta charset="utf-8"> <meta charset="utf-8">

View file

@ -15,122 +15,69 @@
</header> </header>
<main> <main>
<section> <section>
<form> <form>
<label for="channelName">Enter channel name:</label> <label for="channelName">Enter channel name:</label>
<input type="text" id="channelName" placeholder="#huesoperro" aria-describedby="channelNameValidHelper"> <input type="text" id="channelName" placeholder="huesoperro" aria-describedby="channelNameValidHelper">
<small id="channelNameValidHelper"></small> <small id="channelNameValidHelper"></small>
<button id="startButton" type="button">Start chatbot</button> <button id="startButton" type="button">Start chatbot</button>
<button id="stopButton" type="button" disabled style="background-color: #aa0000; border-color: #aa0000">Stop <button id="stopButton" type="button" disabled style="background-color: #aa0000; border-color: #aa0000">Stop
chatbot chatbot
</button> </button>
<br/> <br/>
</form> </form>
</section>
<details> <details>
<summary>Log</summary> <summary>Chatbot settings</summary>
<div><samp id="log"></samp></div> <form>
<label for="automaticGenerationTimer">Automatic generation timer (seconds)</label>
<input type="number" id="automaticGenerationTimer" placeholder="300"
value="{{ automatic_generation_timer }}">
<label for="automaticQuotesTimer">Automatic quotes timer (seconds)</label>
<input type="number" id="automaticQuotesTimer" placeholder="500" value="{{ automatic_quote_timer }}">
<label for="mods">Chatbot mods (comma-separated)</label>
<input type="text" id="mods" placeholder="huesoporro" value="{{ ','.join(mods) or '' }}">
<button id="saveSettings" type="button" style="background-color: #00c482; border-color: #00c482">
Save settings
</button>
</form>
</details> </details>
</section>
</main> </main>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
class ChatbotManager { class ChatbotManager {
constructor() { constructor() {
this.url = getWebsocketProtocol() + window.location.host + "/ws"; this.stopButton = document.getElementById("stopButton");
this.logElement = document.getElementById('log'); this.startButton = document.getElementById("startButton");
this.socket = null; this.automaticGenerationTimerInput = document.getElementById("automaticGenerationTimer");
this.automaticQuotesTimerInput = document.getElementById("automaticQuotesTimer");
this.modsInput = document.getElementById("mods");
this.channelNameInput = document.getElementById("channelName");
} }
log(message) { setEvents() {
console.log(message); document.getElementById('saveSettings').addEventListener('click', () => {
this.logElement.innerHTML += message + '<br>'; chatbotManager.saveBotSettings()
} })
async open() {
return new Promise((resolve, reject) => {
this.socket = new WebSocket(this.url);
this.socket.withCredentials = true;
this.socket.onopen = () => {
this.log("Connected to WebSocket " + this.url);
}
this.socket.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
if (message.command === "chatbot_message") {
this.log(`[${message.data.username}]: ${message.data.message}`);
} else if (message.command === "chatbot_status") {
startButton.disabled = message.data.status === "ok";
stopButton.disabled = message.data.status === "ko";
this.log("Bot status is " + message.data.status)
} else if (message.command === "chatbot_start") {
this.log(message.data.log)
}
} catch (error) {
this.log(`Error parsing message: ${error.message}`);
}
}
this.socket.onerror = (error) => {
this.log(`WebSocket Error: ${error}`);
reject(error);
}
this.socket.onclose = () => {
this.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
resolve();
}
});
}
async startBot() { this.startButton.addEventListener('click', () => {
const channelNameInput = document.getElementById('channelName'); const channelName = this.channelNameInput ? this.channelNameInput.value : '';
const channelName = channelNameInput ? channelNameInput.value : '';
const startCommand = {
command: "chatbot_start",
data: {
channel_name: channelName
}
};
this.socket.send(JSON.stringify(startCommand));
}
async stopBot() {
const stopCommand = {
command: "chatbot_stop",
data: {}
};
this.socket.send(JSON.stringify(stopCommand));
}
}
const chatbotManager = new ChatbotManager();
chatbotManager.open()
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');
if (startButton) {
startButton.addEventListener('click', () => {
// check if the input has text
const channelNameInput = document.getElementById('channelName');
const channelName = channelNameInput ? channelNameInput.value : '';
if (!channelName) { if (!channelName) {
// if channelName is empty show error in the helper and add
// aria-invalid="true" and aria-describedby="channelNameValidHelper" to the input
document.getElementById('channelNameValidHelper').textContent = 'Please enter a channel name'; document.getElementById('channelNameValidHelper').textContent = 'Please enter a channel name';
channelNameInput.setAttribute('aria-invalid', 'true'); this.channelNameInput.setAttribute('aria-invalid', 'true');
return; return;
} }
document.getElementById('channelNameValidHelper').textContent = 'Looks good!'; document.getElementById('channelNameValidHelper').textContent = 'Looks good!';
channelNameInput.setAttribute('aria-invalid', 'false'); this.channelNameInput.setAttribute('aria-invalid', 'false');
chatbotManager.startBot() this.startBot()
.then(() => { .then(() => {
console.log('Chatbot started successfully'); console.log('Chatbot started successfully');
}) })
@ -138,10 +85,8 @@
console.error('Failed to start chatbot', error); console.error('Failed to start chatbot', error);
}); });
}); });
}
if (stopButton) { this.stopButton.addEventListener('click', () => {
stopButton.addEventListener('click', () => {
chatbotManager.stopBot() chatbotManager.stopBot()
.then(() => { .then(() => {
console.log('Chatbot stopped successfully'); console.log('Chatbot stopped successfully');
@ -150,11 +95,100 @@
console.error('Failed to stop chatbot', error); console.error('Failed to stop chatbot', error);
}); });
}); });
setInterval(() => {
this.getBotStatus()
.then(() => {
console.log('Chatbot status retrieved successfully');
})
.catch((error) => {
console.error('Failed to retrieve chatbot status', error);
});
}, 2000);
} }
async startBot() {
// call PUT /bot/start with the channel name as a query param
const channelNameInput = document.getElementById('channelName');
const channelName = channelNameInput ? channelNameInput.value : '';
const response = await fetch(`/api/v1/bot`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"command": "start",
"channel_name": channelName
})
});
const data = await response.json();
console.log(data);
}
async stopBot() {
// call PUT /bot/stop
const response = await fetch(`/api/v1/bot`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"command": "stop",
}),
});
const data = await response.json();
console.log(data);
// disable startButton
startButton.disabled = true;
stopButton.disabled = false;
}
setButtonsStatus(status) {
this.startButton.disabled = status === "ok";
this.stopButton.disabled = status === "ko";
}
async getBotStatus() {
const response = await fetch(`/api/v1/bot`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log(data);
this.setButtonsStatus(data.status)
}
async saveBotSettings() {
const automatic_generation_timer = this.automaticGenerationTimerInput.value
const automatic_quote_timer = this.automaticQuotesTimerInput.value
const mods = this.modsInput.value.split(",")
const response = await fetch(`/api/v1/bot/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'automatic_generation_timer': automatic_generation_timer,
"automatic_quote_timer": automatic_quote_timer,
"mods": mods,
})
})
const data = await response.json()
console.log(data);
if (response.ok){
alert("Settings saved successfully")
}
}
}
const chatbotManager = new ChatbotManager();
chatbotManager.setEvents();
addLogoutEvent() addLogoutEvent()
}); });
</script> </script>
</body> </body>

View file

@ -1,32 +1,17 @@
<!DOCTYPE html> {% include 'header.html' %}
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<link rel="stylesheet" href="/static/css/mvp.css">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
<meta charset="utf-8">
<meta name="description" content="Huesoporro Twitch bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Huesoporro login</title>
</head>
<body> <body>
<header> <header>
<h1>Huesoporro🦴🚬</h1> <h1>Huesoporro🦴🚬</h1>
</header> </header>
<main> <main>
<section> <section>
<form>
<a href="{{ twitch_login_url }}" id="loginButton" type="button" style="color: #9c36b5; border-color: #9c36b5">Login <a role="button" href="{{ twitch_login_url }}" id="loginButton" type="button" style="background-color: #B645CD; border-color: #B645CD">Login
with with
Twitch Twitch
</a> </a>
</form>
</section> </section>
</main> </main>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

394
uv.lock generated
View file

@ -1,6 +1,101 @@
version = 1 version = 1
requires-python = ">=3.11" requires-python = ">=3.11"
[[package]]
name = "aiohappyeyeballs"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 },
]
[[package]]
name = "aiohttp"
version = "3.11.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/c4/3b5a937b16f6c2a0ada842a9066aad0b7a5708427d4a202a07bf09c67cbb/aiohttp-3.11.10.tar.gz", hash = "sha256:b1fc6b45010a8d0ff9e88f9f2418c6fd408c99c211257334aff41597ebece42e", size = 7668832 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/7c/584d5ca19343c9462d054337828f72628e6dc204424f525df59ebfe75d1e/aiohttp-3.11.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:77c4aa15a89847b9891abf97f3d4048f3c2d667e00f8a623c89ad2dccee6771b", size = 708395 },
{ url = "https://files.pythonhosted.org/packages/cd/2d/61c33e01baeb23aebd07620ee4d780ff40f4c17c42289bf02a405f2ac312/aiohttp-3.11.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:909af95a72cedbefe5596f0bdf3055740f96c1a4baa0dd11fd74ca4de0b4e3f1", size = 468281 },
{ url = "https://files.pythonhosted.org/packages/ab/70/0ddb3a61b835068eb0badbe8016b4b65b966bad5f8af0f2d63998ff4cfa4/aiohttp-3.11.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:386fbe79863eb564e9f3615b959e28b222259da0c48fd1be5929ac838bc65683", size = 455345 },
{ url = "https://files.pythonhosted.org/packages/44/8c/4e14e9c1767d9a6ab1af1fbad9df9c77e050b39b6afe9e8343ec1ba96508/aiohttp-3.11.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3de34936eb1a647aa919655ff8d38b618e9f6b7f250cc19a57a4bf7fd2062b6d", size = 1685464 },
{ url = "https://files.pythonhosted.org/packages/ef/6e/1bab78ebb4f5a1c54f0fc10f8d52abc06816a9cb1db52b9c908e3d69f9a8/aiohttp-3.11.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c9527819b29cd2b9f52033e7fb9ff08073df49b4799c89cb5754624ecd98299", size = 1743427 },
{ url = "https://files.pythonhosted.org/packages/5d/5e/c1b03bef621a8cc51ff551ef223c6ac606fabe0e35c950f56d01423ec2aa/aiohttp-3.11.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a96e3e03300b41f261bbfd40dfdbf1c301e87eab7cd61c054b1f2e7c89b9e8", size = 1785188 },
{ url = "https://files.pythonhosted.org/packages/7c/b8/df6d76a149cbd969a58da478baec0be617287c496c842ddf21fe6bce07b3/aiohttp-3.11.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f5635f7b74bcd4f6f72fcd85bea2154b323a9f05226a80bc7398d0c90763b0", size = 1674911 },
{ url = "https://files.pythonhosted.org/packages/ee/8e/e460e7bb820a08cec399971fc3176afc8090dc32fb941f386e0c68bc4ecc/aiohttp-3.11.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03b6002e20938fc6ee0918c81d9e776bebccc84690e2b03ed132331cca065ee5", size = 1619570 },
{ url = "https://files.pythonhosted.org/packages/c2/ae/3b597e09eae4e75b77ee6c65443593d245bfa067ae6a5d895abaf27cce6c/aiohttp-3.11.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6362cc6c23c08d18ddbf0e8c4d5159b5df74fea1a5278ff4f2c79aed3f4e9f46", size = 1653772 },
{ url = "https://files.pythonhosted.org/packages/b8/d1/99852f2925992c4d7004e590344e5398eb163750de2a7c1fbe07f182d3c8/aiohttp-3.11.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3691ed7726fef54e928fe26344d930c0c8575bc968c3e239c2e1a04bd8cf7838", size = 1649787 },
{ url = "https://files.pythonhosted.org/packages/39/c0/ea24627e08d722d5a6a00b3f6c9763fe3ad4650b8485f7a7a56ff932e3af/aiohttp-3.11.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31d5093d3acd02b31c649d3a69bb072d539d4c7659b87caa4f6d2bcf57c2fa2b", size = 1732666 },
{ url = "https://files.pythonhosted.org/packages/f1/27/ab52dee4443ef8bdb26473b53c841caafd2bb637a8d85751694e089913bb/aiohttp-3.11.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8b3cf2dc0f0690a33f2d2b2cb15db87a65f1c609f53c37e226f84edb08d10f52", size = 1754910 },
{ url = "https://files.pythonhosted.org/packages/cd/08/57c919d6b1f3b70bc14433c080a6152bf99454b636eb8a88552de8baaca9/aiohttp-3.11.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbbaea811a2bba171197b08eea288b9402faa2bab2ba0858eecdd0a4105753a3", size = 1692502 },
{ url = "https://files.pythonhosted.org/packages/ae/37/015006f669275735049e0549c37cb79c7a4a9350cbee070bbccb5a5b4b8a/aiohttp-3.11.10-cp311-cp311-win32.whl", hash = "sha256:4b2c7ac59c5698a7a8207ba72d9e9c15b0fc484a560be0788b31312c2c5504e4", size = 416178 },
{ url = "https://files.pythonhosted.org/packages/cf/8d/7bb48ae503989b15114baf9f9b19398c86ae93d30959065bc061b31331ee/aiohttp-3.11.10-cp311-cp311-win_amd64.whl", hash = "sha256:974d3a2cce5fcfa32f06b13ccc8f20c6ad9c51802bb7f829eae8a1845c4019ec", size = 442269 },
{ url = "https://files.pythonhosted.org/packages/25/17/1dbe2f619f77795409c1a13ab395b98ed1b215d3e938cacde9b8ffdac53d/aiohttp-3.11.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b78f053a7ecfc35f0451d961dacdc671f4bcbc2f58241a7c820e9d82559844cf", size = 704448 },
{ url = "https://files.pythonhosted.org/packages/e3/9b/112247ad47e9d7f6640889c6e42cc0ded8c8345dd0033c66bcede799b051/aiohttp-3.11.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab7485222db0959a87fbe8125e233b5a6f01f4400785b36e8a7878170d8c3138", size = 463829 },
{ url = "https://files.pythonhosted.org/packages/8a/36/a64b583771fc673062a7a1374728a6241d49e2eda5a9041fbf248e18c804/aiohttp-3.11.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf14627232dfa8730453752e9cdc210966490992234d77ff90bc8dc0dce361d5", size = 455774 },
{ url = "https://files.pythonhosted.org/packages/e5/75/ee1b8f510978b3de5f185c62535b135e4fc3f5a247ca0c2245137a02d800/aiohttp-3.11.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:076bc454a7e6fd646bc82ea7f98296be0b1219b5e3ef8a488afbdd8e81fbac50", size = 1682134 },
{ url = "https://files.pythonhosted.org/packages/87/46/65e8259432d5f73ca9ebf5edb645ef90e5303724e4e52477516cb4042240/aiohttp-3.11.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:482cafb7dc886bebeb6c9ba7925e03591a62ab34298ee70d3dd47ba966370d2c", size = 1736757 },
{ url = "https://files.pythonhosted.org/packages/03/f6/a6d1e791b7153fb2d101278f7146c0771b0e1569c547f8a8bc3035651984/aiohttp-3.11.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf3d1a519a324af764a46da4115bdbd566b3c73fb793ffb97f9111dbc684fc4d", size = 1793033 },
{ url = "https://files.pythonhosted.org/packages/a8/e9/1ac90733e36e7848693aece522936a13bf17eeb617da662f94adfafc1c25/aiohttp-3.11.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24213ba85a419103e641e55c27dc7ff03536c4873470c2478cce3311ba1eee7b", size = 1691609 },
{ url = "https://files.pythonhosted.org/packages/6d/a6/77b33da5a0bc04566c7ddcca94500f2c2a2334eecab4885387fffd1fc600/aiohttp-3.11.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b99acd4730ad1b196bfb03ee0803e4adac371ae8efa7e1cbc820200fc5ded109", size = 1619082 },
{ url = "https://files.pythonhosted.org/packages/48/94/5bf5f927d9a2fedd2c978adfb70a3680e16f46d178361685b56244eb52ed/aiohttp-3.11.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:14cdb5a9570be5a04eec2ace174a48ae85833c2aadc86de68f55541f66ce42ab", size = 1641186 },
{ url = "https://files.pythonhosted.org/packages/99/2d/e85103aa01d1064e51bc50cb51e7b40150a8ff5d34e5a3173a46b241860b/aiohttp-3.11.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7e97d622cb083e86f18317282084bc9fbf261801b0192c34fe4b1febd9f7ae69", size = 1646280 },
{ url = "https://files.pythonhosted.org/packages/7b/e0/44651fda8c1d865a51b3a81f1956ea55ce16fc568fe7a3e05db7fc22f139/aiohttp-3.11.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:012f176945af138abc10c4a48743327a92b4ca9adc7a0e078077cdb5dbab7be0", size = 1701862 },
{ url = "https://files.pythonhosted.org/packages/4e/1e/0804459ae325a5b95f6f349778fb465f29d2b863e522b6a349db0aaad54c/aiohttp-3.11.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44224d815853962f48fe124748227773acd9686eba6dc102578defd6fc99e8d9", size = 1734373 },
{ url = "https://files.pythonhosted.org/packages/07/87/b8f6721668cad74bcc9c7cfe6d0230b304d1250196b221e54294a0d78dbe/aiohttp-3.11.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c87bf31b7fdab94ae3adbe4a48e711bfc5f89d21cf4c197e75561def39e223bc", size = 1694343 },
{ url = "https://files.pythonhosted.org/packages/4b/20/42813fc60d9178ba9b1b86c58a5441ddb6cf8ffdfe66387345bff173bcff/aiohttp-3.11.10-cp312-cp312-win32.whl", hash = "sha256:06a8e2ee1cbac16fe61e51e0b0c269400e781b13bcfc33f5425912391a542985", size = 411118 },
{ url = "https://files.pythonhosted.org/packages/3a/51/df9c263c861ce93998b5ad2ba3212caab2112d5b66dbe91ddbe90c41ded4/aiohttp-3.11.10-cp312-cp312-win_amd64.whl", hash = "sha256:be2b516f56ea883a3e14dda17059716593526e10fb6303189aaf5503937db408", size = 437424 },
{ url = "https://files.pythonhosted.org/packages/8c/1d/88bfdbe28a3d1ba5b94a235f188f27726caf8ade9a0e13574848f44fe0fe/aiohttp-3.11.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8cc5203b817b748adccb07f36390feb730b1bc5f56683445bfe924fc270b8816", size = 697755 },
{ url = "https://files.pythonhosted.org/packages/86/00/4c4619d6fe5c5be32f74d1422fc719b3e6cd7097af0c9e03877ca9bd4ebc/aiohttp-3.11.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ef359ebc6949e3a34c65ce20230fae70920714367c63afd80ea0c2702902ccf", size = 460440 },
{ url = "https://files.pythonhosted.org/packages/aa/1c/2f927408f50593a29465d198ec3c57c835c8602330233163e8d89c1093db/aiohttp-3.11.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9bca390cb247dbfaec3c664326e034ef23882c3f3bfa5fbf0b56cad0320aaca5", size = 452726 },
{ url = "https://files.pythonhosted.org/packages/06/6a/ff00ed0a2ba45c34b3c366aa5b0004b1a4adcec5a9b5f67dd0648ee1c88a/aiohttp-3.11.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811f23b3351ca532af598405db1093f018edf81368e689d1b508c57dcc6b6a32", size = 1664944 },
{ url = "https://files.pythonhosted.org/packages/02/c2/61923f2a7c2e14d7424b3a526e054f0358f57ccdf5573d4d3d033b01921a/aiohttp-3.11.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddf5f7d877615f6a1e75971bfa5ac88609af3b74796ff3e06879e8422729fd01", size = 1717707 },
{ url = "https://files.pythonhosted.org/packages/8a/08/0d3d074b24d377569ec89d476a95ca918443099c0401bb31b331104e35d1/aiohttp-3.11.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ab29b8a0beb6f8eaf1e5049252cfe74adbaafd39ba91e10f18caeb0e99ffb34", size = 1774890 },
{ url = "https://files.pythonhosted.org/packages/e8/49/052ada2b6e90ed65f0e6a7e548614621b5f8dcd193cb9415d2e6bcecc94a/aiohttp-3.11.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c49a76c1038c2dd116fa443eba26bbb8e6c37e924e2513574856de3b6516be99", size = 1676945 },
{ url = "https://files.pythonhosted.org/packages/7c/9e/0c48e1a48e072a869b8b5e3920c9f6a8092861524a4a6f159cd7e6fda939/aiohttp-3.11.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f3dc0e330575f5b134918976a645e79adf333c0a1439dcf6899a80776c9ab39", size = 1602959 },
{ url = "https://files.pythonhosted.org/packages/ab/98/791f979093ff7f67f80344c182cb0ca4c2c60daed397ecaf454cc8d7a5cd/aiohttp-3.11.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:efb15a17a12497685304b2d976cb4939e55137df7b09fa53f1b6a023f01fcb4e", size = 1618058 },
{ url = "https://files.pythonhosted.org/packages/7b/5d/2d4b05feb3fd68eb7c8335f73c81079b56e582633b91002da695ccb439ef/aiohttp-3.11.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:db1d0b28fcb7f1d35600150c3e4b490775251dea70f894bf15c678fdd84eda6a", size = 1616289 },
{ url = "https://files.pythonhosted.org/packages/50/83/68cc28c00fe681dce6150614f105efe98282da19252cd6e32dfa893bb328/aiohttp-3.11.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:15fccaf62a4889527539ecb86834084ecf6e9ea70588efde86e8bc775e0e7542", size = 1685239 },
{ url = "https://files.pythonhosted.org/packages/16/f9/68fc5c8928f63238ce9314f04f3f59d9190a4db924998bb9be99c7aacce8/aiohttp-3.11.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:593c114a2221444f30749cc5e5f4012488f56bd14de2af44fe23e1e9894a9c60", size = 1715078 },
{ url = "https://files.pythonhosted.org/packages/3f/e0/3dd3f0451c532c77e35780bafb2b6469a046bc15a6ec2e039475a1d2f161/aiohttp-3.11.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7852bbcb4d0d2f0c4d583f40c3bc750ee033265d80598d0f9cb6f372baa6b836", size = 1672544 },
{ url = "https://files.pythonhosted.org/packages/a5/b1/3530ab040dd5d7fb016b47115016f9b3a07ea29593b0e07e53dbe06a380c/aiohttp-3.11.10-cp313-cp313-win32.whl", hash = "sha256:65e55ca7debae8faaffee0ebb4b47a51b4075f01e9b641c31e554fd376595c6c", size = 409984 },
{ url = "https://files.pythonhosted.org/packages/49/1f/deed34e9fca639a7f873d01150d46925d3e1312051eaa591c1aa1f2e6ddc/aiohttp-3.11.10-cp313-cp313-win_amd64.whl", hash = "sha256:beb39a6d60a709ae3fb3516a1581777e7e8b76933bb88c8f4420d875bb0267c6", size = 435837 },
]
[[package]]
name = "aiosignal"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 },
]
[[package]]
name = "aiosqlite"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/3a/22ff5415bf4d296c1e92b07fd746ad42c96781f13295a074d58e77747848/aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7", size = 21691 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c4/c93eb22025a2de6b83263dfe3d7df2e19138e345bca6f18dba7394120930/aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", size = 15564 },
]
[[package]] [[package]]
name = "altgraph" name = "altgraph"
version = "0.17.4" version = "0.17.4"
@ -33,6 +128,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 },
] ]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
]
[[package]]
name = "attrs"
version = "24.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 },
]
[[package]]
name = "caribou"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/74/de2c20c5a24811d69a2a6150d5cd841f2af21ffc65ff964a73f46738bb68/caribou-0.4.1.tar.gz", hash = "sha256:13a66fc9ef9b1c9e9ef220876d59a44ee5eff9e579655252271b7514fc0ad787", size = 14019 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/91/795eccdad6abd41b3634502d0971cb696d2e5c9cede67f8b38ebe30d2b1e/caribou-0.4.1-py2.py3-none-any.whl", hash = "sha256:5c7a036584b34021011f1512620152272924e55ca87c7c805073aeef31c5baed", size = 7203 },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2024.8.30" version = "2024.8.30"
@ -177,6 +299,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/0c/56be52741f75bad4dc6555991fabd2e07b432d333da82c11ad701123888a/ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5", size = 25024 }, { url = "https://files.pythonhosted.org/packages/d7/0c/56be52741f75bad4dc6555991fabd2e07b432d333da82c11ad701123888a/ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5", size = 25024 },
] ]
[[package]]
name = "frozenlist"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 },
{ url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 },
{ url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 },
{ url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 },
{ url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 },
{ url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 },
{ url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 },
{ url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 },
{ url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 },
{ url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 },
{ url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 },
{ url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 },
{ url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 },
{ url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 },
{ url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 },
{ url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 },
{ url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 },
{ url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 },
{ url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 },
{ url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 },
{ url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 },
{ url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 },
{ url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 },
{ url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 },
{ url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 },
{ url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 },
{ url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 },
{ url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 },
{ url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 },
{ url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 },
{ url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 },
{ url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 },
{ url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 },
{ url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 },
{ url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 },
{ url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 },
{ url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 },
{ url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 },
{ url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 },
{ url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 },
{ url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 },
{ url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 },
{ url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 },
{ url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 },
{ url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 },
{ url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 },
]
[[package]] [[package]]
name = "future" name = "future"
version = "1.0.0" version = "1.0.0"
@ -267,13 +443,16 @@ wheels = [
[[package]] [[package]]
name = "huesoporro" name = "huesoporro"
version = "0.2.0" version = "0.2.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" },
{ name = "caribou" },
{ name = "ffmpeg" }, { name = "ffmpeg" },
{ name = "ffmpeg-python" }, { name = "ffmpeg-python" },
{ name = "gtts" }, { name = "gtts" },
{ name = "httpx" }, { name = "httpx" },
{ name = "huey" },
{ name = "litestar", extra = ["standard"] }, { name = "litestar", extra = ["standard"] },
{ name = "loguru" }, { name = "loguru" },
{ name = "nltk" }, { name = "nltk" },
@ -281,6 +460,10 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pyjwt" },
{ name = "pytest" },
{ name = "redis" },
{ name = "twitchio" },
{ name = "twitchwebsocket" }, { name = "twitchwebsocket" },
] ]
@ -291,10 +474,13 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "caribou", specifier = ">=0.4.1" },
{ name = "ffmpeg", specifier = ">=1.4" }, { name = "ffmpeg", specifier = ">=1.4" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0" },
{ name = "gtts", specifier = ">=2.5.4" }, { name = "gtts", specifier = ">=2.5.4" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "huey", specifier = ">=2.5.2" },
{ name = "litestar", extras = ["standard"], specifier = ">=2.13.0" }, { name = "litestar", extras = ["standard"], specifier = ">=2.13.0" },
{ name = "loguru", specifier = ">=0.7.2" }, { name = "loguru", specifier = ">=0.7.2" },
{ name = "nltk", specifier = ">=3.9.1" }, { name = "nltk", specifier = ">=3.9.1" },
@ -302,12 +488,22 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.9.2" }, { name = "pydantic", specifier = ">=2.9.2" },
{ name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" },
{ name = "pyinstaller", specifier = ">=6.11.0" }, { name = "pyinstaller", specifier = ">=6.11.0" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "redis", specifier = ">=5.2.1" },
{ name = "twitchio", specifier = ">=2.10.0" },
{ name = "twitchwebsocket", specifier = ">=1.2.1" }, { name = "twitchwebsocket", specifier = ">=1.2.1" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "mypy", specifier = ">=1.13.0" }] dev = [{ name = "mypy", specifier = ">=1.13.0" }]
[[package]]
name = "huey"
version = "2.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/fe/2e063984cdd512aa71e9c9c2a9200b58a830c532d25ca2c6cbc8e44bf7b7/huey-2.5.2.tar.gz", hash = "sha256:df33db474c05414ed40ee2110e9df692369871734da22d74ffb035a4bd74047f", size = 889357 }
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@ -317,6 +513,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
] ]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "iso8601"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545 },
]
[[package]] [[package]]
name = "jinja2" name = "jinja2"
version = "3.1.4" version = "3.1.4"
@ -637,6 +851,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
] ]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]] [[package]]
name = "polyfactory" name = "polyfactory"
version = "2.18.1" version = "2.18.1"
@ -650,6 +873,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/80/e0bfd57b64009f476112fa81056eb64d9c95bbbbf5bb3257ad010f89907a/polyfactory-2.18.1-py3-none-any.whl", hash = "sha256:1a2b0715e08bfe9f14abc838fc013ab8772cb90e66f2e601e15e1127f0bc1b18", size = 59335 }, { url = "https://files.pythonhosted.org/packages/7e/80/e0bfd57b64009f476112fa81056eb64d9c95bbbbf5bb3257ad010f89907a/polyfactory-2.18.1-py3-none-any.whl", hash = "sha256:1a2b0715e08bfe9f14abc838fc013ab8772cb90e66f2e601e15e1127f0bc1b18", size = 59335 },
] ]
[[package]]
name = "propcache"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/0f/2913b6791ebefb2b25b4efd4bb2299c985e09786b9f5b19184a88e5778dd/propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", size = 79297 },
{ url = "https://files.pythonhosted.org/packages/cf/73/af2053aeccd40b05d6e19058419ac77674daecdd32478088b79375b9ab54/propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", size = 45611 },
{ url = "https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", size = 45146 },
{ url = "https://files.pythonhosted.org/packages/03/7a/793aa12f0537b2e520bf09f4c6833706b63170a211ad042ca71cbf79d9cb/propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", size = 232136 },
{ url = "https://files.pythonhosted.org/packages/f1/38/b921b3168d72111769f648314100558c2ea1d52eb3d1ba7ea5c4aa6f9848/propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", size = 239706 },
{ url = "https://files.pythonhosted.org/packages/14/29/4636f500c69b5edea7786db3c34eb6166f3384b905665ce312a6e42c720c/propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", size = 238531 },
{ url = "https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", size = 231063 },
{ url = "https://files.pythonhosted.org/packages/33/5c/1d961299f3c3b8438301ccfbff0143b69afcc30c05fa28673cface692305/propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", size = 220134 },
{ url = "https://files.pythonhosted.org/packages/00/d0/ed735e76db279ba67a7d3b45ba4c654e7b02bc2f8050671ec365d8665e21/propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", size = 220009 },
{ url = "https://files.pythonhosted.org/packages/75/90/ee8fab7304ad6533872fee982cfff5a53b63d095d78140827d93de22e2d4/propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", size = 212199 },
{ url = "https://files.pythonhosted.org/packages/eb/ec/977ffaf1664f82e90737275873461695d4c9407d52abc2f3c3e24716da13/propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", size = 214827 },
{ url = "https://files.pythonhosted.org/packages/57/48/031fb87ab6081764054821a71b71942161619549396224cbb242922525e8/propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", size = 228009 },
{ url = "https://files.pythonhosted.org/packages/1a/06/ef1390f2524850838f2390421b23a8b298f6ce3396a7cc6d39dedd4047b0/propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", size = 231638 },
{ url = "https://files.pythonhosted.org/packages/38/2a/101e6386d5a93358395da1d41642b79c1ee0f3b12e31727932b069282b1d/propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", size = 222788 },
{ url = "https://files.pythonhosted.org/packages/db/81/786f687951d0979007e05ad9346cd357e50e3d0b0f1a1d6074df334b1bbb/propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", size = 40170 },
{ url = "https://files.pythonhosted.org/packages/cf/59/7cc7037b295d5772eceb426358bb1b86e6cab4616d971bd74275395d100d/propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", size = 44404 },
{ url = "https://files.pythonhosted.org/packages/4c/28/1d205fe49be8b1b4df4c50024e62480a442b1a7b818e734308bb0d17e7fb/propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", size = 79588 },
{ url = "https://files.pythonhosted.org/packages/21/ee/fc4d893f8d81cd4971affef2a6cb542b36617cd1d8ce56b406112cb80bf7/propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", size = 45825 },
{ url = "https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", size = 45357 },
{ url = "https://files.pythonhosted.org/packages/7f/14/7ae06a6cf2a2f1cb382586d5a99efe66b0b3d0c6f9ac2f759e6f7af9d7cf/propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", size = 241869 },
{ url = "https://files.pythonhosted.org/packages/cc/59/227a78be960b54a41124e639e2c39e8807ac0c751c735a900e21315f8c2b/propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", size = 247884 },
{ url = "https://files.pythonhosted.org/packages/84/58/f62b4ffaedf88dc1b17f04d57d8536601e4e030feb26617228ef930c3279/propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", size = 248486 },
{ url = "https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", size = 243649 },
{ url = "https://files.pythonhosted.org/packages/ed/bc/4f7aba7f08f520376c4bb6a20b9a981a581b7f2e385fa0ec9f789bb2d362/propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", size = 229103 },
{ url = "https://files.pythonhosted.org/packages/fe/d5/04ac9cd4e51a57a96f78795e03c5a0ddb8f23ec098b86f92de028d7f2a6b/propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", size = 226607 },
{ url = "https://files.pythonhosted.org/packages/e3/f0/24060d959ea41d7a7cc7fdbf68b31852331aabda914a0c63bdb0e22e96d6/propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", size = 221153 },
{ url = "https://files.pythonhosted.org/packages/77/a7/3ac76045a077b3e4de4859a0753010765e45749bdf53bd02bc4d372da1a0/propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", size = 222151 },
{ url = "https://files.pythonhosted.org/packages/e7/af/5e29da6f80cebab3f5a4dcd2a3240e7f56f2c4abf51cbfcc99be34e17f0b/propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", size = 233812 },
{ url = "https://files.pythonhosted.org/packages/8c/89/ebe3ad52642cc5509eaa453e9f4b94b374d81bae3265c59d5c2d98efa1b4/propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", size = 238829 },
{ url = "https://files.pythonhosted.org/packages/e9/2f/6b32f273fa02e978b7577159eae7471b3cfb88b48563b1c2578b2d7ca0bb/propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", size = 230704 },
{ url = "https://files.pythonhosted.org/packages/5c/2e/f40ae6ff5624a5f77edd7b8359b208b5455ea113f68309e2b00a2e1426b6/propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", size = 40050 },
{ url = "https://files.pythonhosted.org/packages/3b/77/a92c3ef994e47180862b9d7d11e37624fb1c00a16d61faf55115d970628b/propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", size = 44117 },
{ url = "https://files.pythonhosted.org/packages/0f/2a/329e0547cf2def8857157f9477669043e75524cc3e6251cef332b3ff256f/propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", size = 77002 },
{ url = "https://files.pythonhosted.org/packages/12/2d/c4df5415e2382f840dc2ecbca0eeb2293024bc28e57a80392f2012b4708c/propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", size = 44639 },
{ url = "https://files.pythonhosted.org/packages/d0/5a/21aaa4ea2f326edaa4e240959ac8b8386ea31dedfdaa636a3544d9e7a408/propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", size = 44049 },
{ url = "https://files.pythonhosted.org/packages/4e/3e/021b6cd86c0acc90d74784ccbb66808b0bd36067a1bf3e2deb0f3845f618/propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", size = 224819 },
{ url = "https://files.pythonhosted.org/packages/3c/57/c2fdeed1b3b8918b1770a133ba5c43ad3d78e18285b0c06364861ef5cc38/propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", size = 229625 },
{ url = "https://files.pythonhosted.org/packages/9d/81/70d4ff57bf2877b5780b466471bebf5892f851a7e2ca0ae7ffd728220281/propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", size = 232934 },
{ url = "https://files.pythonhosted.org/packages/3c/b9/bb51ea95d73b3fb4100cb95adbd4e1acaf2cbb1fd1083f5468eeb4a099a8/propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", size = 227361 },
{ url = "https://files.pythonhosted.org/packages/f1/20/3c6d696cd6fd70b29445960cc803b1851a1131e7a2e4ee261ee48e002bcd/propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", size = 213904 },
{ url = "https://files.pythonhosted.org/packages/a1/cb/1593bfc5ac6d40c010fa823f128056d6bc25b667f5393781e37d62f12005/propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", size = 212632 },
{ url = "https://files.pythonhosted.org/packages/6d/5c/e95617e222be14a34c709442a0ec179f3207f8a2b900273720501a70ec5e/propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", size = 207897 },
{ url = "https://files.pythonhosted.org/packages/8e/3b/56c5ab3dc00f6375fbcdeefdede5adf9bee94f1fab04adc8db118f0f9e25/propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", size = 208118 },
{ url = "https://files.pythonhosted.org/packages/86/25/d7ef738323fbc6ebcbce33eb2a19c5e07a89a3df2fded206065bd5e868a9/propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", size = 217851 },
{ url = "https://files.pythonhosted.org/packages/b3/77/763e6cef1852cf1ba740590364ec50309b89d1c818e3256d3929eb92fabf/propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", size = 222630 },
{ url = "https://files.pythonhosted.org/packages/4f/e9/0f86be33602089c701696fbed8d8c4c07b6ee9605c5b7536fd27ed540c5b/propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", size = 216269 },
{ url = "https://files.pythonhosted.org/packages/cc/02/5ac83217d522394b6a2e81a2e888167e7ca629ef6569a3f09852d6dcb01a/propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", size = 39472 },
{ url = "https://files.pythonhosted.org/packages/f4/33/d6f5420252a36034bc8a3a01171bc55b4bff5df50d1c63d9caa50693662f/propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", size = 43363 },
{ url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.10.3" version = "2.10.3"
@ -780,6 +1060,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/64/445861ee7a5fd32874c0f6cfe8222aacc8feda22539332e0d8ff50dadec6/pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10", size = 338417 }, { url = "https://files.pythonhosted.org/packages/a9/64/445861ee7a5fd32874c0f6cfe8222aacc8feda22539332e0d8ff50dadec6/pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10", size = 338417 },
] ]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
]
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -845,6 +1149,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
] ]
[[package]]
name = "redis"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 },
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "2024.11.6" version = "2024.11.6"
@ -979,6 +1295,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
] ]
[[package]]
name = "twitchio"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "iso8601" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/61/3f177d7e48af2816bd7bc639a7d5c4f2fa0ff3ae46faf36754b3c3676de9/twitchio-2.10.0.tar.gz", hash = "sha256:3ae5e17b3764eff24ad7d12decb473c9d34f18510b5d705e0bbe6fcf0b06fd75", size = 114393 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 },
]
[[package]] [[package]]
name = "twitchwebsocket" name = "twitchwebsocket"
version = "1.2.1" version = "1.2.1"
@ -1155,3 +1485,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
] ]
[[package]]
name = "yarl"
version = "1.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555 },
{ url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351 },
{ url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286 },
{ url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649 },
{ url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623 },
{ url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007 },
{ url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145 },
{ url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133 },
{ url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967 },
{ url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397 },
{ url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206 },
{ url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089 },
{ url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267 },
{ url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141 },
{ url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402 },
{ url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030 },
{ url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644 },
{ url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962 },
{ url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795 },
{ url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368 },
{ url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314 },
{ url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987 },
{ url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914 },
{ url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765 },
{ url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444 },
{ url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760 },
{ url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484 },
{ url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864 },
{ url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537 },
{ url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861 },
{ url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097 },
{ url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399 },
{ url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789 },
{ url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144 },
{ url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974 },
{ url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587 },
{ url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386 },
{ url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421 },
{ url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384 },
{ url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689 },
{ url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453 },
{ url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872 },
{ url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497 },
{ url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981 },
{ url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229 },
{ url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383 },
{ url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152 },
{ 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 },
]