feat: revamp authentication -- remove twitch's tokens from our own wrapper token

This commit is contained in:
cătălin 2025-01-17 18:15:58 +01:00
commit 50900986fa
No known key found for this signature in database
31 changed files with 736 additions and 155 deletions

View file

@ -3,10 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1733788855,
"lastModified": 1735530587,
"owner": "cachix",
"repo": "devenv",
"rev": "d59fee8696cd48f69cf79f65992269df9891ba86",
"rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3",
"type": "github"
},
"original": {
@ -31,6 +31,21 @@
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
@ -66,12 +81,32 @@
"type": "github"
}
},
"nixpkgs-python": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733319315,
"owner": "cachix",
"repo": "nixpkgs-python",
"rev": "01263eeb28c09f143d59cd6b0b7c4cc8478efd48",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "nixpkgs-python",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1733730953,
"lastModified": 1735286948,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7109b680d161993918b0a126f38bc39763e5a709",
"rev": "31ac92f9628682b294026f0860e14587a09ffb4b",
"type": "github"
},
"original": {
@ -83,7 +118,7 @@
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"flake-compat": "flake-compat_2",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
@ -91,10 +126,10 @@
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1733665616,
"lastModified": 1734797603,
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a",
"rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498",
"type": "github"
},
"original": {
@ -107,6 +142,7 @@
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"nixpkgs-python": "nixpkgs-python",
"pre-commit-hooks": "pre-commit-hooks"
}
}

View file

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

View file

@ -1,15 +1,8 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend
nixpkgs-python:
url: github:cachix/nixpkgs-python
inputs:
nixpkgs:
follows: nixpkgs

View file

@ -0,0 +1,35 @@
"""
This module contains a Caribou migration.
Migration Name: sentences
Migration Version: 20241219191711
"""
def upgrade(connection):
# update table `sentences` to have a user_id row
# which references users.id
# and a channel VARCHAR(255) row
sql = """
DROP TABLE IF EXISTS sentences;
"""
connection.execute(sql)
connection.commit()
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,
user_id VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -0,0 +1,53 @@
"""
This module contains a Caribou migration.
Migration Name: user_external_auth
Migration Version: 20250112153541
"""
def upgrade(connection):
"""
- delete access_token, refresh_token, and expires_at from users
- add external_auth table which will store the external auths:
- type: twitch or discord
- credentials: JSON
"""
sql = """
ALTER TABLE users DROP COLUMN access_token;
"""
connection.execute(sql)
sql = """
ALTER TABLE users DROP COLUMN refresh_token;
"""
connection.execute(sql)
sql = """
ALTER TABLE users DROP COLUMN expires_at;
"""
connection.execute(sql)
sql = """
CREATE TABLE external_auth(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type VARCHAR(255) NOT NULL,
credentials JSON NOT NULL
);
"""
connection.execute(sql)
sql = """
CREATE TABLE user_external_auth(
user_id VARCHAR(255) NOT NULL,
external_auth_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (external_auth_id) REFERENCES external_auth(id)
);
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -0,0 +1,35 @@
"""
This module contains a Caribou migration.
Migration Name: external_auth_json
Migration Version: 20250113142241
"""
def upgrade(connection):
"""remove tables:
- external_auth
- user_external_auth
add column to users table:
- external_auth JSON
"""
sql = """
DROP TABLE IF EXISTS external_auth;
"""
connection.execute(sql)
sql = """
DROP TABLE IF EXISTS user_external_auth;
"""
connection.execute(sql)
sql = """
ALTER TABLE users ADD COLUMN external_auth JSON;
"""
connection.execute(sql)
connection.commit()
def downgrade(connection):
# add your downgrade step here
pass

View file

@ -21,6 +21,8 @@ dependencies = [
"pyjwt>=2.10.1",
"twitchio>=2.10.0",
"redis>=5.2.1",
"pytz>=2024.2",
"discord-py>=2.4.0",
]
[tool.uv]

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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")

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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", () => {

View file

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

View file

@ -5,7 +5,7 @@ from caribou.migrate import Database as CaribouDatabase
from caribou.migrate import load_migrations
from src.huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, TwitchAuth, User
from src.huesoporro.models import ChatbotSettings, User
from src.huesoporro.settings import Settings
from src.huesoporro.svc.backoff_service import BackoffService
from src.huesoporro.svc.is_mod import IsModSvc
@ -15,11 +15,10 @@ from src.huesoporro.svc.is_mod import IsModSvc
def user() -> User:
return User(
user="huesoporro",
expires_at=1671234567.0,
twitch_auth=TwitchAuth(
access_token="test_access_token", # noqa: S106
refresh_token="test_refresh_token", # noqa: S106
),
external_auth={
"twitch": {"token": "twitch_token"},
"discord": {"token": "discord_token"},
},
)

53
tests/test_repos.py Normal file
View file

@ -0,0 +1,53 @@
import json
import pytest
from src.huesoporro.infra.repos import UserRepo
from src.huesoporro.models import User
@pytest.fixture
async def user_repo(s, db, user: User):
async with db.get_client() as client:
await client.execute(
"INSERT INTO users (user, external_auth) VALUES (?, ?)",
(user.user, json.dumps(user.external_auth)),
)
return UserRepo(s=s)
async def test_get_user(user_repo: UserRepo, user: User):
db_user = await user_repo.get_by_user(user.user)
assert db_user == user
async def test_get_user_returns_none(user_repo: UserRepo):
assert await user_repo.get_by_user("unknown_user") is None
async def test_create_user(user_repo: UserRepo):
new_user = User(
user="new_user", external_auth={"twitch": {"token": "twitch_token"}}
)
assert await user_repo.create(new_user) == new_user
async def test_update_users_tokens(user_repo: UserRepo, user: User):
new_tokens = {"twitch": {"token": "new_tokens"}}
user.external_auth = new_tokens # type: ignore[assignment]
assert await user_repo.update(user) == user
async def test_update_non_existing_user_raises_value_error(user_repo: UserRepo):
with pytest.raises(ValueError, match="User unknown_user does not exist"):
await user_repo.update(
User(
user="unknown_user", external_auth={"twitch": {"token": "twitch_token"}}
)
)
async def test_delete_user(user_repo: UserRepo, user: User):
assert await user_repo.delete(user) is None
assert await user_repo.get_by_user(user.user) is None

25
uv.lock generated
View file

@ -283,6 +283,18 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "discord-py"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/af/80cab4015722d3bee175509b7249a11d5adf77b5ff4c27f268558079d149/discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5", size = 1027707 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/10/3c44e9331a5ec3bae8b2919d51f611a5b94e179563b1b89eb6423a8f43eb/discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d", size = 1125988 },
]
[[package]]
name = "editorconfig"
version = "0.12.4"
@ -465,6 +477,7 @@ source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "caribou" },
{ name = "discord-py" },
{ name = "gtts" },
{ name = "httpx" },
{ name = "litestar", extra = ["standard"] },
@ -474,6 +487,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pytz" },
{ name = "redis" },
{ name = "twitchio" },
]
@ -491,6 +505,7 @@ dev = [
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "caribou", specifier = ">=0.4.1" },
{ name = "discord-py", specifier = ">=2.4.0" },
{ name = "gtts", specifier = ">=2.5.4" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "litestar", extras = ["standard"], specifier = ">=2.13.0" },
@ -500,6 +515,7 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.9.2" },
{ name = "pydantic-settings", specifier = ">=2.6.0" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pytz", specifier = ">=2024.2" },
{ name = "redis", specifier = ">=5.2.1" },
{ name = "twitchio", specifier = ">=2.10.0" },
]
@ -1101,6 +1117,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]]
name = "pytz"
version = "2024.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"