feat: revamp authentication -- remove twitch's tokens from our own wrapper token
This commit is contained in:
parent
3186afe96d
commit
50900986fa
31 changed files with 736 additions and 155 deletions
27
src/huesoporro/actions/authenticate.py
Normal file
27
src/huesoporro/actions/authenticate.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from src.huesoporro.infra.authenticator import TwitchAuthenticator
|
||||
from src.huesoporro.infra.repos import UserRepo
|
||||
from src.huesoporro.models import User
|
||||
from src.huesoporro.settings import Settings
|
||||
|
||||
|
||||
class AuthenticateAction(BaseModel):
|
||||
user_repo: UserRepo
|
||||
authenticator: TwitchAuthenticator
|
||||
s: Settings
|
||||
|
||||
async def run(
|
||||
self,
|
||||
auth_code: str,
|
||||
):
|
||||
tokens = await self.authenticator.get_token(auth_code)
|
||||
username = tokens.userinfo["preferred_username"]
|
||||
if username not in self.s.allowed_users:
|
||||
raise ValueError(f"User {username} is not allowed to use this bot")
|
||||
user = User(user=username, external_auth={"twitch": tokens.model_dump()})
|
||||
if await self.user_repo.get_by_user(user.user):
|
||||
await self.user_repo.update(user)
|
||||
else:
|
||||
await self.user_repo.create(user)
|
||||
return user.encode()
|
||||
38
src/huesoporro/actions/get_user_by_jwt.py
Normal file
38
src/huesoporro/actions/get_user_by_jwt.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.huesoporro.infra.authenticator import TwitchAuthenticator
|
||||
from src.huesoporro.infra.repos import UserRepo
|
||||
from src.huesoporro.models import User
|
||||
from src.huesoporro.settings import Settings
|
||||
|
||||
|
||||
class GetUserByJWTAction(BaseModel):
|
||||
user_repo: UserRepo
|
||||
authenticator: TwitchAuthenticator
|
||||
s: Settings
|
||||
|
||||
async def run(
|
||||
self,
|
||||
jwt_token: str,
|
||||
) -> User:
|
||||
user_data = User.decode(jwt_token)
|
||||
username = user_data["user"]
|
||||
user = await self.user_repo.get_by_user(username)
|
||||
if not user:
|
||||
raise ValueError(f"User {username} not found")
|
||||
is_valid = await self.authenticator.token_is_valid(
|
||||
user.external_auth["twitch"]["access_token"]
|
||||
)
|
||||
logger.info(f"Token {user} is valid: {is_valid}")
|
||||
if not is_valid:
|
||||
logger.info(f"Refreshing token for user {user}")
|
||||
new_tokens = await self.authenticator.refresh_token(
|
||||
user.external_auth["twitch"]["refresh_token"]
|
||||
)
|
||||
user.external_auth["twitch"]["access_token"] = new_tokens["access_token"] # type: ignore[index]
|
||||
user.external_auth["twitch"]["refresh_token"] = new_tokens["refresh_token"] # type: ignore[index]
|
||||
await self.user_repo.update(user)
|
||||
return user
|
||||
|
||||
return user
|
||||
27
src/huesoporro/actions/refresh.py
Normal file
27
src/huesoporro/actions/refresh.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from src.huesoporro.infra.authenticator import TwitchAuthenticator
|
||||
from src.huesoporro.infra.repos import UserRepo
|
||||
from src.huesoporro.models import User
|
||||
from src.huesoporro.settings import Settings
|
||||
|
||||
|
||||
class RefreshAction(BaseModel):
|
||||
user_repo: UserRepo
|
||||
authenticator: TwitchAuthenticator
|
||||
s: Settings
|
||||
|
||||
async def run(self, user: User):
|
||||
is_valid = await self.authenticator.token_is_valid(
|
||||
user.external_auth["twitch"]["access_token"]
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
new_tokens = await self.authenticator.refresh_token(
|
||||
user.external_auth["twitch"]["refresh_token"]
|
||||
)
|
||||
user.external_auth["twitch"]["access_token"] = new_tokens["access_token"] # type: ignore[index]
|
||||
user.external_auth["twitch"]["refresh_token"] = new_tokens["refresh_token"] # type: ignore[index]
|
||||
await self.user_repo.update(user)
|
||||
return user.encode()
|
||||
return None
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
from litestar import Request
|
||||
from litestar.exceptions import HTTPException
|
||||
|
||||
from src.huesoporro.actions.authenticate import AuthenticateAction
|
||||
from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction
|
||||
from src.huesoporro.infra.authenticator import TwitchAuthenticator
|
||||
from src.huesoporro.infra.db import Database
|
||||
from src.huesoporro.infra.repos import UserRepo
|
||||
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.get_sentences_svc import SentencesGetterSvc
|
||||
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
|
||||
|
||||
|
||||
|
|
@ -22,27 +25,43 @@ def get_db(s: Settings):
|
|||
return Database(s=s)
|
||||
|
||||
|
||||
async def authenticate(request: Request) -> User:
|
||||
async def get_get_user_by_jwt_action(
|
||||
user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings
|
||||
):
|
||||
return GetUserByJWTAction(user_repo=user_repo, authenticator=authenticator, s=s)
|
||||
|
||||
|
||||
async def authenticate(
|
||||
request: Request, get_user_by_jwt_action: GetUserByJWTAction
|
||||
) -> User:
|
||||
token = request.query_params.get("huesoporro_token")
|
||||
if token:
|
||||
return User.decode(token)
|
||||
return await get_user_by_jwt_action.run(token)
|
||||
|
||||
cookies = request.cookies.get("huesoporroAuth")
|
||||
if cookies:
|
||||
return User.decode(cookies)
|
||||
return await get_user_by_jwt_action.run(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)
|
||||
|
||||
|
||||
async def get_sentences_svc(db: Database):
|
||||
return SentencesGetterSvc(db=db)
|
||||
|
||||
|
||||
async def get_user_repo(s: Settings):
|
||||
return UserRepo(s=s)
|
||||
|
||||
|
||||
async def get_authenticate_action(
|
||||
user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings
|
||||
):
|
||||
return AuthenticateAction(user_repo=user_repo, authenticator=authenticator, s=s)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ from litestar.template import TemplateConfig
|
|||
|
||||
from src.huesoporro.api.dependencies import (
|
||||
authenticate,
|
||||
get_authenticate_action,
|
||||
get_authenticator,
|
||||
get_chatbot_settings_svc,
|
||||
get_code_authenticator_svc,
|
||||
get_db,
|
||||
get_get_user_by_jwt_action,
|
||||
get_sentences_svc,
|
||||
get_settings,
|
||||
get_user_repo,
|
||||
store_chatbot_settings_svc,
|
||||
)
|
||||
from src.huesoporro.api.errors import (
|
||||
|
|
@ -24,10 +27,12 @@ from src.huesoporro.api.routes.api import (
|
|||
get_bot_settings,
|
||||
get_bot_status,
|
||||
get_index,
|
||||
get_sentences,
|
||||
get_tts_overlay,
|
||||
get_tts_permalink,
|
||||
manage_bot,
|
||||
save_bot_settings,
|
||||
save_new_sentence,
|
||||
)
|
||||
from src.huesoporro.api.routes.auth import get_code, login
|
||||
from src.huesoporro.bot import BotsManager
|
||||
|
|
@ -52,6 +57,8 @@ def create_app():
|
|||
get_bot_status,
|
||||
save_bot_settings,
|
||||
get_bot_settings,
|
||||
get_sentences,
|
||||
save_new_sentence,
|
||||
],
|
||||
static_files_config=(
|
||||
StaticFilesConfig(
|
||||
|
|
@ -77,10 +84,14 @@ def create_app():
|
|||
"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),
|
||||
"sgs": Provide(get_sentences_svc),
|
||||
"authenticator": Provide(get_authenticator),
|
||||
"authenticate_action": Provide(get_authenticate_action),
|
||||
"user_repo": Provide(get_user_repo),
|
||||
"get_user_by_jwt_action": Provide(get_get_user_by_jwt_action),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from typing import Literal
|
||||
|
||||
from litestar import MediaType, Response, get, put
|
||||
from litestar import MediaType, Response, get, post, 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.get_sentences_svc import SentencesGetterSvc
|
||||
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
|
||||
|
||||
|
||||
|
|
@ -98,3 +99,17 @@ async def save_bot_settings(
|
|||
) -> dict:
|
||||
await sbs.run(user=user, bot_settings=data)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@get("/sentences")
|
||||
async def get_sentences(user: User, sgs: SentencesGetterSvc) -> Template:
|
||||
sentences = await sgs.run(user=user)
|
||||
return Template(
|
||||
template_name="sentences.html",
|
||||
context={"sentences": [sentence.model_dump() for sentence in sentences]},
|
||||
)
|
||||
|
||||
|
||||
@post("/api/v1/sentences")
|
||||
async def save_new_sentence(user: User, data: dict) -> dict:
|
||||
return {"id": 54, "sentence": data["sentence"]}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import secrets
|
|||
from litestar import MediaType, get
|
||||
from litestar.response import Redirect, Template
|
||||
|
||||
from src.huesoporro.actions.authenticate import AuthenticateAction
|
||||
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()})
|
||||
async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect:
|
||||
token = await authenticate_action.run(code)
|
||||
return Redirect("/", cookies={"huesoporroAuth": token})
|
||||
|
||||
|
||||
@get(
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ 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]
|
||||
token=user.twitch_access_token, prefix="!", initial_channels=[channel]
|
||||
)
|
||||
self.channel = channel
|
||||
self.user = user
|
||||
|
|
|
|||
|
|
@ -30,7 +30,15 @@ class TwitchAuthenticator(BaseModel):
|
|||
return await self.refresh_token(response.json()["refresh_token"])
|
||||
|
||||
response.raise_for_status()
|
||||
return TwitchAuth(**response.json())
|
||||
profile = await self.get_userinfo(response.json()["access_token"])
|
||||
return TwitchAuth(**response.json(), userinfo=profile)
|
||||
|
||||
async def get_userinfo(self, access_token):
|
||||
response = await self.client.get(
|
||||
"/oauth2/userinfo", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> TwitchAuth:
|
||||
response = await self.client.post(
|
||||
|
|
@ -60,3 +68,9 @@ class TwitchAuthenticator(BaseModel):
|
|||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
return user
|
||||
|
||||
async def token_is_valid(self, access_token: str) -> bool:
|
||||
response = await self.client.get(
|
||||
"/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"}
|
||||
)
|
||||
return response.status_code == 200 # noqa: PLR2004
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import aiosqlite
|
|||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.huesoporro.models import ChatbotSettings, User
|
||||
from src.huesoporro.models import ChatbotSettings, Sentence, User
|
||||
from src.huesoporro.settings import Settings
|
||||
|
||||
|
||||
|
|
@ -24,36 +24,6 @@ class Database(BaseModel):
|
|||
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(
|
||||
|
|
@ -133,3 +103,14 @@ class Database(BaseModel):
|
|||
) as cursor,
|
||||
):
|
||||
return await cursor.fetchone()
|
||||
|
||||
async def get_sentences(self, user: User) -> list[Sentence]:
|
||||
async with self.get_client() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM sentences WHERE user_id = ?", (user.user,)
|
||||
) as cursor:
|
||||
result = await cursor.fetchall()
|
||||
if not result:
|
||||
return []
|
||||
return [Sentence(user=user, **dict(value)) for value in result]
|
||||
|
|
|
|||
114
src/huesoporro/infra/repos.py
Normal file
114
src/huesoporro/infra/repos.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.huesoporro.models import User
|
||||
from src.huesoporro.settings import Settings
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
class IRepo(BaseModel, ABC, Generic[T]):
|
||||
s: Settings = Field(default_factory=Settings.get)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_client(self, auto_commit=True):
|
||||
async with aiosqlite.connect(self.s.db_filepath) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
yield db
|
||||
if auto_commit:
|
||||
await db.commit()
|
||||
|
||||
@abstractmethod
|
||||
async def create(self, obj: T, auto_commit=True) -> T:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, obj: T, auto_commit=True) -> T:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, obj: T, auto_commit=True):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, obj_id: int | str, auto_commit=True) -> T | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list(
|
||||
self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[T]:
|
||||
pass
|
||||
|
||||
|
||||
class UserRepo(IRepo[User]):
|
||||
async def get_by_id(self, obj_id: int | str, auto_commit=True) -> User | None:
|
||||
raise NotImplementedError("Not implemented since it's not needed")
|
||||
|
||||
async def create(self, obj: User, auto_commit=True) -> User:
|
||||
async with self.get_client(auto_commit=auto_commit) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO users (user, external_auth) VALUES (?, ?)",
|
||||
(obj.user, json.dumps(obj.external_auth)),
|
||||
)
|
||||
return obj
|
||||
|
||||
async def update(self, obj: User, auto_commit=True) -> User:
|
||||
if not await self.get_by_user(obj.user):
|
||||
raise ValueError(f"User {obj.user} does not exist")
|
||||
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET external_auth = ?
|
||||
WHERE user = ?
|
||||
RETURNING *
|
||||
""",
|
||||
(json.dumps(obj.external_auth), obj.user),
|
||||
) as cursor,
|
||||
):
|
||||
data = await cursor.fetchone()
|
||||
return User(
|
||||
user=data["user"], external_auth=json.loads(data["external_auth"])
|
||||
)
|
||||
|
||||
async def delete(self, obj: User, auto_commit=True):
|
||||
async with self.get_client(auto_commit=auto_commit) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM users WHERE user = ?
|
||||
""",
|
||||
(obj.user,),
|
||||
)
|
||||
|
||||
async def get_by_user(self, user: str, auto_commit=True) -> User | None:
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
SELECT * FROM users WHERE user = ?
|
||||
""",
|
||||
(user,),
|
||||
) as cursor,
|
||||
):
|
||||
data = await cursor.fetchone()
|
||||
if not data:
|
||||
return None
|
||||
return User(
|
||||
user=data["user"], external_auth=json.loads(data["external_auth"])
|
||||
)
|
||||
|
||||
async def list(
|
||||
self, obj: User, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[User]:
|
||||
raise NotImplementedError("Not implemented since it's not needed")
|
||||
|
||||
async def count(self, obj: User, auto_commit=True):
|
||||
raise NotImplementedError("Not implemented since it's not needed")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Self
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
|
@ -9,28 +9,39 @@ from src.huesoporro.settings import Settings
|
|||
class TwitchAuth(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
userinfo: dict
|
||||
|
||||
|
||||
class ExternalAuth(BaseModel):
|
||||
credentials: dict
|
||||
type: Literal["twitch"] = "twitch"
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
user: str
|
||||
expires_at: float
|
||||
twitch_auth: TwitchAuth
|
||||
external_auth: dict[Literal["twitch", "discord"], dict]
|
||||
|
||||
def encode(self, settings: Settings | None = None) -> str:
|
||||
def encode(
|
||||
self, settings: Settings | None = None, exclude_fields: set[str] | None = None
|
||||
) -> str:
|
||||
s = settings or Settings.get()
|
||||
exclude_fields = exclude_fields or {"external_auth"}
|
||||
return jwt.encode(
|
||||
self.model_dump(),
|
||||
self.model_dump(exclude=exclude_fields),
|
||||
key=s.jwt_secret.get_secret_value(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, token: str, settings: Settings | None = None) -> Self:
|
||||
def decode(cls, token: str, settings: Settings | None = None) -> dict:
|
||||
s = settings or Settings.get()
|
||||
decoded = jwt.decode(
|
||||
return jwt.decode(
|
||||
token, key=s.jwt_secret.get_secret_value(), algorithms=["HS256"]
|
||||
)
|
||||
return cls(**decoded)
|
||||
|
||||
@property
|
||||
def twitch_access_token(self):
|
||||
return self.external_auth["twitch"]["access_token"]
|
||||
|
||||
|
||||
class ChatbotSettings(BaseModel):
|
||||
|
|
@ -50,3 +61,11 @@ class ChatbotSettings(BaseModel):
|
|||
if isinstance(v, str):
|
||||
return v.split(",")
|
||||
return v
|
||||
|
||||
|
||||
class Sentence(BaseModel):
|
||||
id: int
|
||||
sentence: str
|
||||
created_at: float
|
||||
last_updated_at: float
|
||||
user: User
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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
|
||||
11
src/huesoporro/svc/get_sentences_svc.py
Normal file
11
src/huesoporro/svc/get_sentences_svc.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from src.huesoporro.infra.db import Database
|
||||
from src.huesoporro.models import Sentence, User
|
||||
|
||||
|
||||
class SentencesGetterSvc(BaseModel):
|
||||
db: Database
|
||||
|
||||
async def run(self, user: User) -> list[Sentence]:
|
||||
return await self.db.get_sentences(user=user)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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
|
||||
|
|
@ -2,15 +2,24 @@
|
|||
<html lang="en" xmlns="http://www.w3.org/1999/html">
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/css/pico/pico.classless.min.css">
|
||||
<link rel="stylesheet" href="/static/css/pico/pico.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">
|
||||
<meta name="description" content="Huesoporro Twitch bot">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Atkinson Hyperlegible', sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>Huesoporro</title>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li>Chatbot</li>
|
||||
<li><a href="#" disabled>TTS</a></li>
|
||||
<li><a href="#" disabled="true">Le Funny</a></li>
|
||||
{% include 'le_funny_dropdown.html' %}
|
||||
</ul>
|
||||
{% include 'logout.html' %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<main class="container">
|
||||
<section>
|
||||
<form>
|
||||
<label for="channelName">Enter channel name:</label>
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
.catch((error) => {
|
||||
console.error('Failed to retrieve chatbot status', error);
|
||||
});
|
||||
}, 2000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async startBot() {
|
||||
|
|
@ -184,6 +184,8 @@
|
|||
|
||||
const chatbotManager = new ChatbotManager();
|
||||
chatbotManager.setEvents();
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
10
src/huesoporro/templates/le_funny_dropdown.html
Normal file
10
src/huesoporro/templates/le_funny_dropdown.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<li>
|
||||
<details class="dropdown">
|
||||
<summary>Le Funny</summary>
|
||||
<ul dir="rtl">
|
||||
<li><a href="/sentences" >Sentences</a></li>
|
||||
<li><a href="#" disabled>Quotes</a></li>
|
||||
<li><a href="#" disabled>Copypastas</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{% include 'header.html' %}
|
||||
<body>
|
||||
<header>
|
||||
<header class="container">
|
||||
<h1>Huesoporro🦴🚬</h1>
|
||||
</header>
|
||||
<main>
|
||||
<main class="container">
|
||||
<section>
|
||||
<form>
|
||||
<a role="button" href="{{ twitch_login_url }}" id="loginButton" type="button"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<ul>
|
||||
<li><a id="logoutButton" href="#" style="color: #aa0000;">Logout</a></li>
|
||||
<li><a id="logoutButton" href="#" class="pico-background-red-600">Logout</a></li>
|
||||
</ul>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
|
|
|||
|
|
@ -2,35 +2,134 @@
|
|||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li><a href="/">Chatbot</a></li>
|
||||
<li><a href="/tts">TTS</a></li>
|
||||
<li>Le Funny</li>
|
||||
{% include 'le_funny_dropdown.html' %}
|
||||
</ul>
|
||||
{% include 'logout.html' %}
|
||||
</nav>
|
||||
<h1>Huesoporro🦴🍃</h1>
|
||||
<script>
|
||||
function timestampToAwareDatetime(timestamp) {
|
||||
const awareDatetime = new Date(timestamp);
|
||||
awareDatetime.setMinutes(awareDatetime.getMinutes() - awareDatetime.getTimezoneOffset());
|
||||
return awareDatetime;
|
||||
}
|
||||
|
||||
function prettifyTimestamp(timestamp) {
|
||||
const awareDatetime = timestampToAwareDatetime(timestamp);
|
||||
return awareDatetime.toLocaleString();
|
||||
}
|
||||
|
||||
function saveNewSentence(shortHash) {
|
||||
/*
|
||||
Save a newly-added sentence by POST'ing against /api/v1/sentences.
|
||||
|
||||
- if response is not 200, return nothing (validation logic to be added later
|
||||
- if response is 200, retrieve save-new-${shortHash} and delete-new-${shortHas} and change their ids
|
||||
to save-${newSentenceIdRetrievedFromResponse} and delete-${newSentenceIdRetrievedFromResponse}
|
||||
assume a body like: {"id": 1, "sentence": "New sentence"}
|
||||
|
||||
*/
|
||||
const sentenceTextInput = document.getElementById(`newSentence${shortHash}`);
|
||||
const sentence = sentenceTextInput.value;
|
||||
// if sentence is empty, return
|
||||
if (!sentence) {
|
||||
return;
|
||||
}
|
||||
const sentenceData = {
|
||||
"sentence": sentence
|
||||
}
|
||||
fetch(`/api/v1/sentences`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(sentenceData)
|
||||
}).then(response => {
|
||||
response.json().then(data => {
|
||||
console.log(data);
|
||||
const saveButton = document.getElementById(`saveNew${shortHash}`);
|
||||
const deleteButton = document.getElementById(`deleteNew${shortHash}`);
|
||||
saveButton.id = `save-${data.id}`;
|
||||
deleteButton.id = `delete-${data.id}`;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function saveExistingSentence(id) {
|
||||
// get sentence value by
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const timestampCells = document.querySelectorAll('.timestamp-cell');
|
||||
console.log(timestampCells);
|
||||
timestampCells.forEach(cell => {
|
||||
const timestampStr = cell.textContent.trim();
|
||||
const timestamp = parseFloat(timestampStr) * 1000;
|
||||
cell.textContent = prettifyTimestamp(timestamp);
|
||||
});
|
||||
|
||||
const addButton = document.getElementById('add-sentence');
|
||||
addButton.addEventListener('click', () => {
|
||||
// add a new empty row at the top of the tbody
|
||||
const sentencesTable = document.getElementById('sentencesTable');
|
||||
const newRow = sentencesTable.insertRow();
|
||||
const nowPrettyDate = prettifyTimestamp(Date.now());
|
||||
const shortHash = Math.random().toString(36).substring(2, 10);
|
||||
newRow.innerHTML = `
|
||||
<td><textarea placeholder="Enter new sentence" id="newSentence${shortHash}"></textarea></td>
|
||||
<td class="timestamp-cell">${nowPrettyDate}</td>
|
||||
<td>
|
||||
<div class="container">
|
||||
<button id="saveNew${shortHash}" style="background-color: #00c482; border-color: #00c482" onclick="saveNewSentence('${shortHash}')">
|
||||
Save
|
||||
</button>
|
||||
<button id="deleteNew${shortHash}" style="border-color: #aa0000; background-color: #aa0000" onclick="this.parentElement.parentElement.parentElement.remove()">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</header>
|
||||
<main>
|
||||
<main class="container">
|
||||
<section>
|
||||
<table>
|
||||
<form role="search">
|
||||
<input name="search" type="search" placeholder="Search sentence"/>
|
||||
<input type="submit" value="Search"/>
|
||||
<button id="add-sentence" class="secondary" type="button">Add</button>
|
||||
</form>
|
||||
<table id="sentencesTable">
|
||||
<thead style="background-color: white; color: black">
|
||||
<tr>
|
||||
<th>Sentence</th>
|
||||
<th>Action</th>
|
||||
<th scope="col">Sentence</th>
|
||||
<th scope="col">Last modified</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sentencesTableBody">
|
||||
{% for sentence in sentences %}
|
||||
<tr>
|
||||
<td>{{ sentence.sentence }}</td>
|
||||
<td><textarea>{{ sentence.sentence }}</textarea></td>
|
||||
<td class="timestamp-cell">{{ sentence.last_updated_at }}</td>
|
||||
<td>
|
||||
<button id="delete-{{ sentence.id }}" style="border-color: #aa0000; background-color: #aa0000">
|
||||
Delete
|
||||
</button>
|
||||
<div class="container">
|
||||
<button id="save-{{ sentence.id }}" style="background-color: #00c482; border-color: #00c482">
|
||||
Save
|
||||
</button>
|
||||
<button id="delete-{{ sentence.id }}" style="border-color: #aa0000; background-color: #aa0000">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue