feat: add migrations, api bot endpoints and revamp the whole twitch backend by making use of twitchio
This commit is contained in:
parent
8799bab900
commit
4c534de47b
45 changed files with 1718 additions and 1109 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -114,18 +114,5 @@ src/huesoporro/tts_files/
|
|||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
|
|
|||
|
|
@ -32,8 +32,13 @@ COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./
|
|||
RUN uv sync
|
||||
|
||||
COPY --chown=$USERNAME src/ src/
|
||||
COPY --chown=$USERNAME migrations/ migrations/
|
||||
|
||||
|
||||
FROM base AS serve
|
||||
|
||||
CMD ["make", "serve"]
|
||||
|
||||
FROM base AS migrate
|
||||
|
||||
CMD ["make", "migrate"]
|
||||
3
Makefile
3
Makefile
|
|
@ -16,3 +16,6 @@ serve:
|
|||
|
||||
build:
|
||||
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/
|
||||
|
|
@ -15,10 +15,10 @@ type: application
|
|||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.2.1
|
||||
version: 0.2.2
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "0.2.1"
|
||||
appVersion: "0.2.2"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@ spec:
|
|||
mountPath: /data
|
||||
securityContext:
|
||||
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 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ image:
|
|||
# This sets the pull policy for images.
|
||||
pullPolicy: Always
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: "0.2.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/
|
||||
imagePullSecrets: []
|
||||
|
|
|
|||
29
migrations/20241213175820_auth.py
Normal file
29
migrations/20241213175820_auth.py
Normal 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
|
||||
38
migrations/20241216204252_quotes.py
Normal file
38
migrations/20241216204252_quotes.py
Normal 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
|
||||
28
migrations/20241217000747_settings.py
Normal file
28
migrations/20241217000747_settings.py
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "huesoporro"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "Misc Twitch bots"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
|
|
@ -20,6 +20,13 @@ dependencies = [
|
|||
"gtts>=2.5.4",
|
||||
"litestar[standard]>=2.13.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]
|
||||
|
|
|
|||
0
src/huesoporro/actions/__init__.py
Normal file
0
src/huesoporro/actions/__init__.py
Normal file
18
src/huesoporro/actions/store_quote.py
Normal file
18
src/huesoporro/actions/store_quote.py
Normal 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}."
|
||||
0
src/huesoporro/api/__init__.py
Normal file
0
src/huesoporro/api/__init__.py
Normal file
48
src/huesoporro/api/dependencies.py
Normal file
48
src/huesoporro/api/dependencies.py
Normal 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)
|
||||
45
src/huesoporro/api/errors.py
Normal file
45
src/huesoporro/api/errors.py
Normal 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()
|
||||
85
src/huesoporro/api/main.py
Normal file
85
src/huesoporro/api/main.py
Normal 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),
|
||||
},
|
||||
)
|
||||
0
src/huesoporro/api/routes/__init__.py
Normal file
0
src/huesoporro/api/routes/__init__.py
Normal file
86
src/huesoporro/api/routes/api.py
Normal file
86
src/huesoporro/api/routes/api.py
Normal 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"}
|
||||
31
src/huesoporro/api/routes/auth.py
Normal file
31
src/huesoporro/api/routes/auth.py
Normal 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
150
src/huesoporro/bot.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
0
src/huesoporro/infra/__init__.py
Normal file
0
src/huesoporro/infra/__init__.py
Normal file
62
src/huesoporro/infra/authenticator.py
Normal file
62
src/huesoporro/infra/authenticator.py
Normal 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
134
src/huesoporro/infra/db.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,248 +1,7 @@
|
|||
import json
|
||||
import secrets
|
||||
from json import JSONDecodeError
|
||||
|
||||
import httpx
|
||||
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.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__":
|
||||
settings = Settings.get()
|
||||
|
|
|
|||
50
src/huesoporro/models.py
Normal file
50
src/huesoporro/models.py
Normal 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
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, HttpUrl, field_validator
|
||||
from pydantic import Field, HttpUrl, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
|
|
@ -21,6 +21,8 @@ class Settings(BaseSettings):
|
|||
default_factory=lambda: Path(__file__).parent / "huesoporro.db"
|
||||
)
|
||||
twitch_client_id: str
|
||||
twitch_client_secret: SecretStr
|
||||
jwt_secret: SecretStr
|
||||
twitch_scopes: list[str] = Field(
|
||||
default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"]
|
||||
)
|
||||
|
|
|
|||
0
src/huesoporro/svc/__init__.py
Normal file
0
src/huesoporro/svc/__init__.py
Normal file
26
src/huesoporro/svc/authenticate.py
Normal file
26
src/huesoporro/svc/authenticate.py
Normal 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
|
||||
158
src/huesoporro/svc/generate.py
Normal file
158
src/huesoporro/svc/generate.py
Normal 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
|
||||
11
src/huesoporro/svc/get_chatbot_settings.py
Normal file
11
src/huesoporro/svc/get_chatbot_settings.py
Normal 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)
|
||||
10
src/huesoporro/svc/get_random_quote.py
Normal file
10
src/huesoporro/svc/get_random_quote.py
Normal 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)
|
||||
10
src/huesoporro/svc/hello.py
Normal file
10
src/huesoporro/svc/hello.py
Normal 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}"
|
||||
12
src/huesoporro/svc/is_mod.py
Normal file
12
src/huesoporro/svc/is_mod.py
Normal 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
|
||||
27
src/huesoporro/svc/refresh.py
Normal file
27
src/huesoporro/svc/refresh.py
Normal 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
|
||||
63
src/huesoporro/svc/store.py
Normal file
63
src/huesoporro/svc/store.py
Normal 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)
|
||||
10
src/huesoporro/svc/store_quote.py
Normal file
10
src/huesoporro/svc/store_quote.py
Normal 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)
|
||||
15
src/huesoporro/svc/store_settings.py
Normal file
15
src/huesoporro/svc/store_settings.py
Normal 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
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
<head>
|
||||
<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"
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -15,146 +15,180 @@
|
|||
</header>
|
||||
<main>
|
||||
<section>
|
||||
|
||||
<form>
|
||||
<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>
|
||||
<button id="startButton" type="button">Start chatbot</button>
|
||||
|
||||
<button id="stopButton" type="button" disabled style="background-color: #aa0000; border-color: #aa0000">Stop
|
||||
chatbot
|
||||
</button>
|
||||
<br/>
|
||||
|
||||
</form>
|
||||
<details>
|
||||
<summary>Chatbot settings</summary>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Log</summary>
|
||||
<div><samp id="log"></samp></div>
|
||||
</details>
|
||||
</main>
|
||||
<script>
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
class ChatbotManager {
|
||||
|
||||
constructor() {
|
||||
this.url = getWebsocketProtocol() + window.location.host + "/ws";
|
||||
this.logElement = document.getElementById('log');
|
||||
this.socket = null;
|
||||
this.stopButton = document.getElementById("stopButton");
|
||||
this.startButton = document.getElementById("startButton");
|
||||
this.automaticGenerationTimerInput = document.getElementById("automaticGenerationTimer");
|
||||
this.automaticQuotesTimerInput = document.getElementById("automaticQuotesTimer");
|
||||
this.modsInput = document.getElementById("mods");
|
||||
this.channelNameInput = document.getElementById("channelName");
|
||||
|
||||
}
|
||||
|
||||
log(message) {
|
||||
console.log(message);
|
||||
this.logElement.innerHTML += message + '<br>';
|
||||
}
|
||||
setEvents() {
|
||||
document.getElementById('saveSettings').addEventListener('click', () => {
|
||||
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();
|
||||
|
||||
this.startButton.addEventListener('click', () => {
|
||||
const channelName = this.channelNameInput ? this.channelNameInput.value : '';
|
||||
if (!channelName) {
|
||||
document.getElementById('channelNameValidHelper').textContent = 'Please enter a channel name';
|
||||
this.channelNameInput.setAttribute('aria-invalid', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('channelNameValidHelper').textContent = 'Looks good!';
|
||||
this.channelNameInput.setAttribute('aria-invalid', 'false');
|
||||
|
||||
this.startBot()
|
||||
.then(() => {
|
||||
console.log('Chatbot started successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start chatbot', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.stopButton.addEventListener('click', () => {
|
||||
chatbotManager.stopBot()
|
||||
.then(() => {
|
||||
console.log('Chatbot stopped successfully');
|
||||
})
|
||||
.catch((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 startCommand = {
|
||||
command: "chatbot_start",
|
||||
data: {
|
||||
channel_name: channelName
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.send(JSON.stringify(startCommand));
|
||||
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() {
|
||||
const stopCommand = {
|
||||
command: "chatbot_stop",
|
||||
data: {}
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify(stopCommand));
|
||||
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.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 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';
|
||||
channelNameInput.setAttribute('aria-invalid', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('channelNameValidHelper').textContent = 'Looks good!';
|
||||
channelNameInput.setAttribute('aria-invalid', 'false');
|
||||
|
||||
chatbotManager.startBot()
|
||||
.then(() => {
|
||||
console.log('Chatbot started successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start chatbot', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (stopButton) {
|
||||
stopButton.addEventListener('click', () => {
|
||||
chatbotManager.stopBot()
|
||||
.then(() => {
|
||||
console.log('Chatbot stopped successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to stop chatbot', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
chatbotManager.setEvents();
|
||||
|
||||
addLogoutEvent()
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,17 @@
|
|||
<!DOCTYPE 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>
|
||||
|
||||
{% include 'header.html' %}
|
||||
<body>
|
||||
<header>
|
||||
<h1>Huesoporro🦴🚬</h1>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
|
||||
<a href="{{ twitch_login_url }}" id="loginButton" type="button" style="color: #9c36b5; border-color: #9c36b5">Login
|
||||
with
|
||||
Twitch
|
||||
</a>
|
||||
<form>
|
||||
<a role="button" href="{{ twitch_login_url }}" id="loginButton" type="button" style="background-color: #B645CD; border-color: #B645CD">Login
|
||||
with
|
||||
Twitch
|
||||
</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
|
|
|||
394
uv.lock
generated
394
uv.lock
generated
|
|
@ -1,6 +1,101 @@
|
|||
version = 1
|
||||
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]]
|
||||
name = "altgraph"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "future"
|
||||
version = "1.0.0"
|
||||
|
|
@ -267,13 +443,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "huesoporro"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "caribou" },
|
||||
{ name = "ffmpeg" },
|
||||
{ name = "ffmpeg-python" },
|
||||
{ name = "gtts" },
|
||||
{ name = "httpx" },
|
||||
{ name = "huey" },
|
||||
{ name = "litestar", extra = ["standard"] },
|
||||
{ name = "loguru" },
|
||||
{ name = "nltk" },
|
||||
|
|
@ -281,6 +460,10 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pytest" },
|
||||
{ name = "redis" },
|
||||
{ name = "twitchio" },
|
||||
{ name = "twitchwebsocket" },
|
||||
]
|
||||
|
||||
|
|
@ -291,10 +474,13 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "caribou", specifier = ">=0.4.1" },
|
||||
{ name = "ffmpeg", specifier = ">=1.4" },
|
||||
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
||||
{ name = "gtts", specifier = ">=2.5.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.0" },
|
||||
{ name = "huey", specifier = ">=2.5.2" },
|
||||
{ name = "litestar", extras = ["standard"], specifier = ">=2.13.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.2" },
|
||||
{ name = "nltk", specifier = ">=3.9.1" },
|
||||
|
|
@ -302,12 +488,22 @@ requires-dist = [
|
|||
{ name = "pydantic", specifier = ">=2.9.2" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.6.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" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
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]]
|
||||
name = "idna"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "jinja2"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "polyfactory"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dateutil"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "regex"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "twitchwebsocket"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1155,3 +1485,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66
|
|||
wheels = [
|
||||
{ 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 },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue