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
50
devenv.lock
50
devenv.lock
|
|
@ -3,10 +3,10 @@
|
||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1733788855,
|
"lastModified": 1735530587,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "d59fee8696cd48f69cf79f65992269df9891ba86",
|
"rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -31,6 +31,21 @@
|
||||||
"type": "github"
|
"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": {
|
"gitignore": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
|
|
@ -66,12 +81,32 @@
|
||||||
"type": "github"
|
"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": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1733730953,
|
"lastModified": 1735286948,
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "7109b680d161993918b0a126f38bc39763e5a709",
|
"rev": "31ac92f9628682b294026f0860e14587a09ffb4b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -83,7 +118,7 @@
|
||||||
},
|
},
|
||||||
"pre-commit-hooks": {
|
"pre-commit-hooks": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-compat": "flake-compat",
|
"flake-compat": "flake-compat_2",
|
||||||
"gitignore": "gitignore",
|
"gitignore": "gitignore",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
|
|
@ -91,10 +126,10 @@
|
||||||
"nixpkgs-stable": "nixpkgs-stable"
|
"nixpkgs-stable": "nixpkgs-stable"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1733665616,
|
"lastModified": 1734797603,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "pre-commit-hooks.nix",
|
"repo": "pre-commit-hooks.nix",
|
||||||
"rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a",
|
"rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -107,6 +142,7 @@
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixpkgs-python": "nixpkgs-python",
|
||||||
"pre-commit-hooks": "pre-commit-hooks"
|
"pre-commit-hooks": "pre-commit-hooks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,15 @@
|
||||||
|
|
||||||
packages = [ pkgs.git ];
|
packages = [ pkgs.git ];
|
||||||
|
|
||||||
|
certificates = [
|
||||||
|
"id.twitch.tv"
|
||||||
|
"twitch.tv"
|
||||||
|
"discord.com"
|
||||||
|
];
|
||||||
|
|
||||||
languages.python.enable = true;
|
languages.python.enable = true;
|
||||||
languages.python.uv.enable = true;
|
languages.python.uv.enable = true;
|
||||||
|
languages.python.version = "3.12.8";
|
||||||
|
|
||||||
scripts.hello.exec = ''
|
scripts.hello.exec = ''
|
||||||
echo hello from $GREET
|
echo hello from $GREET
|
||||||
|
|
|
||||||
17
devenv.yaml
17
devenv.yaml
|
|
@ -1,15 +1,8 @@
|
||||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
|
||||||
inputs:
|
inputs:
|
||||||
nixpkgs:
|
nixpkgs:
|
||||||
url: github:cachix/devenv-nixpkgs/rolling
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
|
nixpkgs-python:
|
||||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
url: github:cachix/nixpkgs-python
|
||||||
# allowUnfree: true
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
# If you're willing to use a package that's vulnerable
|
follows: nixpkgs
|
||||||
# permittedInsecurePackages:
|
|
||||||
# - "openssl-1.1.1w"
|
|
||||||
|
|
||||||
# If you have more than one devenv you can merge them
|
|
||||||
#imports:
|
|
||||||
# - ./backend
|
|
||||||
|
|
|
||||||
35
migrations/20241219191711_sentences.py
Normal file
35
migrations/20241219191711_sentences.py
Normal 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
|
||||||
53
migrations/20250112153541_user_external_auth.py
Normal file
53
migrations/20250112153541_user_external_auth.py
Normal 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
|
||||||
35
migrations/20250113142241_external_auth_json.py
Normal file
35
migrations/20250113142241_external_auth_json.py
Normal 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
|
||||||
|
|
@ -21,6 +21,8 @@ dependencies = [
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"twitchio>=2.10.0",
|
"twitchio>=2.10.0",
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
|
"pytz>=2024.2",
|
||||||
|
"discord-py>=2.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
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 import Request
|
||||||
from litestar.exceptions import HTTPException
|
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.authenticator import TwitchAuthenticator
|
||||||
from src.huesoporro.infra.db import Database
|
from src.huesoporro.infra.db import Database
|
||||||
|
from src.huesoporro.infra.repos import UserRepo
|
||||||
from src.huesoporro.models import User
|
from src.huesoporro.models import User
|
||||||
from src.huesoporro.settings import Settings
|
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_chatbot_settings import ChatbotSettingsGetterSvc
|
||||||
|
from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc
|
||||||
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
|
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,27 +25,43 @@ def get_db(s: Settings):
|
||||||
return Database(s=s)
|
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")
|
token = request.query_params.get("huesoporro_token")
|
||||||
if token:
|
if token:
|
||||||
return User.decode(token)
|
return await get_user_by_jwt_action.run(token)
|
||||||
|
|
||||||
cookies = request.cookies.get("huesoporroAuth")
|
cookies = request.cookies.get("huesoporroAuth")
|
||||||
if cookies:
|
if cookies:
|
||||||
return User.decode(cookies)
|
return await get_user_by_jwt_action.run(cookies)
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
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):
|
async def get_chatbot_settings_svc(db: Database):
|
||||||
return ChatbotSettingsGetterSvc(db=db)
|
return ChatbotSettingsGetterSvc(db=db)
|
||||||
|
|
||||||
|
|
||||||
async def store_chatbot_settings_svc(db: Database):
|
async def store_chatbot_settings_svc(db: Database):
|
||||||
return ChatbotSettingsStorerSvc(db=db)
|
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 (
|
from src.huesoporro.api.dependencies import (
|
||||||
authenticate,
|
authenticate,
|
||||||
|
get_authenticate_action,
|
||||||
get_authenticator,
|
get_authenticator,
|
||||||
get_chatbot_settings_svc,
|
get_chatbot_settings_svc,
|
||||||
get_code_authenticator_svc,
|
|
||||||
get_db,
|
get_db,
|
||||||
|
get_get_user_by_jwt_action,
|
||||||
|
get_sentences_svc,
|
||||||
get_settings,
|
get_settings,
|
||||||
|
get_user_repo,
|
||||||
store_chatbot_settings_svc,
|
store_chatbot_settings_svc,
|
||||||
)
|
)
|
||||||
from src.huesoporro.api.errors import (
|
from src.huesoporro.api.errors import (
|
||||||
|
|
@ -24,10 +27,12 @@ from src.huesoporro.api.routes.api import (
|
||||||
get_bot_settings,
|
get_bot_settings,
|
||||||
get_bot_status,
|
get_bot_status,
|
||||||
get_index,
|
get_index,
|
||||||
|
get_sentences,
|
||||||
get_tts_overlay,
|
get_tts_overlay,
|
||||||
get_tts_permalink,
|
get_tts_permalink,
|
||||||
manage_bot,
|
manage_bot,
|
||||||
save_bot_settings,
|
save_bot_settings,
|
||||||
|
save_new_sentence,
|
||||||
)
|
)
|
||||||
from src.huesoporro.api.routes.auth import get_code, login
|
from src.huesoporro.api.routes.auth import get_code, login
|
||||||
from src.huesoporro.bot import BotsManager
|
from src.huesoporro.bot import BotsManager
|
||||||
|
|
@ -52,6 +57,8 @@ def create_app():
|
||||||
get_bot_status,
|
get_bot_status,
|
||||||
save_bot_settings,
|
save_bot_settings,
|
||||||
get_bot_settings,
|
get_bot_settings,
|
||||||
|
get_sentences,
|
||||||
|
save_new_sentence,
|
||||||
],
|
],
|
||||||
static_files_config=(
|
static_files_config=(
|
||||||
StaticFilesConfig(
|
StaticFilesConfig(
|
||||||
|
|
@ -77,10 +84,14 @@ def create_app():
|
||||||
"a": Provide(get_authenticator, use_cache=True),
|
"a": Provide(get_authenticator, use_cache=True),
|
||||||
"user": Provide(authenticate),
|
"user": Provide(authenticate),
|
||||||
"db": Provide(get_db, use_cache=True),
|
"db": Provide(get_db, use_cache=True),
|
||||||
"code_authenticator_svc": Provide(get_code_authenticator_svc),
|
|
||||||
"bm": Provide(BotsManager, use_cache=True),
|
"bm": Provide(BotsManager, use_cache=True),
|
||||||
"gbs": Provide(get_chatbot_settings_svc),
|
"gbs": Provide(get_chatbot_settings_svc),
|
||||||
"sbs": Provide(store_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 typing import Literal
|
||||||
|
|
||||||
from litestar import MediaType, Response, get, put
|
from litestar import MediaType, Response, get, post, put
|
||||||
from litestar.response import Template
|
from litestar.response import Template
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from src.huesoporro.bot import BotsManager
|
from src.huesoporro.bot import BotsManager
|
||||||
from src.huesoporro.models import ChatbotSettings, User
|
from src.huesoporro.models import ChatbotSettings, User
|
||||||
from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
|
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
|
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,3 +99,17 @@ async def save_bot_settings(
|
||||||
) -> dict:
|
) -> dict:
|
||||||
await sbs.run(user=user, bot_settings=data)
|
await sbs.run(user=user, bot_settings=data)
|
||||||
return {"status": "ok"}
|
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 import MediaType, get
|
||||||
from litestar.response import Redirect, Template
|
from litestar.response import Redirect, Template
|
||||||
|
|
||||||
|
from src.huesoporro.actions.authenticate import AuthenticateAction
|
||||||
from src.huesoporro.settings import Settings
|
from src.huesoporro.settings import Settings
|
||||||
from src.huesoporro.svc.authenticate import CodeAuthenticatorSvc
|
|
||||||
|
|
||||||
|
|
||||||
@get(path="/o/code")
|
@get(path="/o/code")
|
||||||
async def get_code(code: str, code_authenticator_svc: CodeAuthenticatorSvc) -> Redirect:
|
async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect:
|
||||||
user = await code_authenticator_svc.run(code)
|
token = await authenticate_action.run(code)
|
||||||
return Redirect("/", cookies={"huesoporroAuth": user.encode()})
|
return Redirect("/", cookies={"huesoporroAuth": token})
|
||||||
|
|
||||||
|
|
||||||
@get(
|
@get(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from src.huesoporro.svc.store_quote import QuoteStorerSvc
|
||||||
class Bot(commands.Bot):
|
class Bot(commands.Bot):
|
||||||
def __init__(self, user: User, chatbot_settings: ChatbotSettings, channel: str):
|
def __init__(self, user: User, chatbot_settings: ChatbotSettings, channel: str):
|
||||||
super().__init__(
|
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.channel = channel
|
||||||
self.user = user
|
self.user = user
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,15 @@ class TwitchAuthenticator(BaseModel):
|
||||||
return await self.refresh_token(response.json()["refresh_token"])
|
return await self.refresh_token(response.json()["refresh_token"])
|
||||||
|
|
||||||
response.raise_for_status()
|
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:
|
async def refresh_token(self, refresh_token: str) -> TwitchAuth:
|
||||||
response = await self.client.post(
|
response = await self.client.post(
|
||||||
|
|
@ -60,3 +68,9 @@ class TwitchAuthenticator(BaseModel):
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
return user
|
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 loguru import logger
|
||||||
from pydantic import BaseModel, Field
|
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
|
from src.huesoporro.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,36 +24,6 @@ class Database(BaseModel):
|
||||||
def get_now() -> float:
|
def get_now() -> float:
|
||||||
return datetime.datetime.now(datetime.UTC).timestamp()
|
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 def save_quote(self, channel: str, quote: str, author: str, auto_commit=True):
|
||||||
async with self.get_client(auto_commit=auto_commit) as db:
|
async with self.get_client(auto_commit=auto_commit) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -133,3 +103,14 @@ class Database(BaseModel):
|
||||||
) as cursor,
|
) as cursor,
|
||||||
):
|
):
|
||||||
return await cursor.fetchone()
|
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
|
import jwt
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
@ -9,28 +9,39 @@ from src.huesoporro.settings import Settings
|
||||||
class TwitchAuth(BaseModel):
|
class TwitchAuth(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
|
userinfo: dict
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAuth(BaseModel):
|
||||||
|
credentials: dict
|
||||||
|
type: Literal["twitch"] = "twitch"
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
expires_at: float
|
external_auth: dict[Literal["twitch", "discord"], dict]
|
||||||
twitch_auth: TwitchAuth
|
|
||||||
|
|
||||||
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()
|
s = settings or Settings.get()
|
||||||
|
exclude_fields = exclude_fields or {"external_auth"}
|
||||||
return jwt.encode(
|
return jwt.encode(
|
||||||
self.model_dump(),
|
self.model_dump(exclude=exclude_fields),
|
||||||
key=s.jwt_secret.get_secret_value(),
|
key=s.jwt_secret.get_secret_value(),
|
||||||
algorithm="HS256",
|
algorithm="HS256",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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()
|
s = settings or Settings.get()
|
||||||
decoded = jwt.decode(
|
return jwt.decode(
|
||||||
token, key=s.jwt_secret.get_secret_value(), algorithms=["HS256"]
|
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):
|
class ChatbotSettings(BaseModel):
|
||||||
|
|
@ -50,3 +61,11 @@ class ChatbotSettings(BaseModel):
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
return v.split(",")
|
return v.split(",")
|
||||||
return v
|
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">
|
<html lang="en" xmlns="http://www.w3.org/1999/html">
|
||||||
|
|
||||||
<head>
|
<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="stylesheet" href="/static/css/pico/pico.colors.min.css">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
|
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="description" content="Huesoporro Twitch bot">
|
<meta name="description" content="Huesoporro Twitch bot">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<script src="/static/js/utils.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Atkinson Hyperlegible', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<title>Huesoporro</title>
|
<title>Huesoporro</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav class="container">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Chatbot</li>
|
<li>Chatbot</li>
|
||||||
<li><a href="#" disabled>TTS</a></li>
|
<li><a href="#" disabled>TTS</a></li>
|
||||||
<li><a href="#" disabled="true">Le Funny</a></li>
|
{% include 'le_funny_dropdown.html' %}
|
||||||
</ul>
|
</ul>
|
||||||
{% include 'logout.html' %}
|
{% include 'logout.html' %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="container">
|
||||||
<section>
|
<section>
|
||||||
<form>
|
<form>
|
||||||
<label for="channelName">Enter channel name:</label>
|
<label for="channelName">Enter channel name:</label>
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to retrieve chatbot status', error);
|
console.error('Failed to retrieve chatbot status', error);
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBot() {
|
async startBot() {
|
||||||
|
|
@ -184,6 +184,8 @@
|
||||||
|
|
||||||
const chatbotManager = new ChatbotManager();
|
const chatbotManager = new ChatbotManager();
|
||||||
chatbotManager.setEvents();
|
chatbotManager.setEvents();
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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' %}
|
{% include 'header.html' %}
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="container">
|
||||||
<h1>Huesoporro🦴🚬</h1>
|
<h1>Huesoporro🦴🚬</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="container">
|
||||||
<section>
|
<section>
|
||||||
<form>
|
<form>
|
||||||
<a role="button" href="{{ twitch_login_url }}" id="loginButton" type="button"
|
<a role="button" href="{{ twitch_login_url }}" id="loginButton" type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<ul>
|
<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>
|
</ul>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,134 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav class="container">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/">Chatbot</a></li>
|
<li><a href="/">Chatbot</a></li>
|
||||||
<li><a href="/tts">TTS</a></li>
|
<li><a href="/tts">TTS</a></li>
|
||||||
<li>Le Funny</li>
|
{% include 'le_funny_dropdown.html' %}
|
||||||
</ul>
|
</ul>
|
||||||
{% include 'logout.html' %}
|
{% include 'logout.html' %}
|
||||||
</nav>
|
</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>
|
</header>
|
||||||
<main>
|
<main class="container">
|
||||||
<section>
|
<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">
|
<thead style="background-color: white; color: black">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Sentence</th>
|
<th scope="col">Sentence</th>
|
||||||
<th>Action</th>
|
<th scope="col">Last modified</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
<tbody id="sentencesTableBody">
|
||||||
{% for sentence in sentences %}
|
{% for sentence in sentences %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ sentence.sentence }}</td>
|
<td><textarea>{{ sentence.sentence }}</textarea></td>
|
||||||
|
<td class="timestamp-cell">{{ sentence.last_updated_at }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<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">
|
<button id="delete-{{ sentence.id }}" style="border-color: #aa0000; background-color: #aa0000">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from caribou.migrate import Database as CaribouDatabase
|
||||||
from caribou.migrate import load_migrations
|
from caribou.migrate import load_migrations
|
||||||
|
|
||||||
from src.huesoporro.infra.db import Database
|
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.settings import Settings
|
||||||
from src.huesoporro.svc.backoff_service import BackoffService
|
from src.huesoporro.svc.backoff_service import BackoffService
|
||||||
from src.huesoporro.svc.is_mod import IsModSvc
|
from src.huesoporro.svc.is_mod import IsModSvc
|
||||||
|
|
@ -15,11 +15,10 @@ from src.huesoporro.svc.is_mod import IsModSvc
|
||||||
def user() -> User:
|
def user() -> User:
|
||||||
return User(
|
return User(
|
||||||
user="huesoporro",
|
user="huesoporro",
|
||||||
expires_at=1671234567.0,
|
external_auth={
|
||||||
twitch_auth=TwitchAuth(
|
"twitch": {"token": "twitch_token"},
|
||||||
access_token="test_access_token", # noqa: S106
|
"discord": {"token": "discord_token"},
|
||||||
refresh_token="test_refresh_token", # noqa: S106
|
},
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
53
tests/test_repos.py
Normal file
53
tests/test_repos.py
Normal 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
25
uv.lock
generated
|
|
@ -283,6 +283,18 @@ toml = [
|
||||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
{ 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]]
|
[[package]]
|
||||||
name = "editorconfig"
|
name = "editorconfig"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
|
|
@ -465,6 +477,7 @@ source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "caribou" },
|
{ name = "caribou" },
|
||||||
|
{ name = "discord-py" },
|
||||||
{ name = "gtts" },
|
{ name = "gtts" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "litestar", extra = ["standard"] },
|
{ name = "litestar", extra = ["standard"] },
|
||||||
|
|
@ -474,6 +487,7 @@ dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
|
{ name = "pytz" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "twitchio" },
|
{ name = "twitchio" },
|
||||||
]
|
]
|
||||||
|
|
@ -491,6 +505,7 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||||
{ name = "caribou", specifier = ">=0.4.1" },
|
{ name = "caribou", specifier = ">=0.4.1" },
|
||||||
|
{ name = "discord-py", specifier = ">=2.4.0" },
|
||||||
{ name = "gtts", specifier = ">=2.5.4" },
|
{ name = "gtts", specifier = ">=2.5.4" },
|
||||||
{ name = "httpx", specifier = ">=0.28.0" },
|
{ name = "httpx", specifier = ">=0.28.0" },
|
||||||
{ name = "litestar", extras = ["standard"], specifier = ">=2.13.0" },
|
{ name = "litestar", extras = ["standard"], specifier = ">=2.13.0" },
|
||||||
|
|
@ -500,6 +515,7 @@ requires-dist = [
|
||||||
{ name = "pydantic", specifier = ">=2.9.2" },
|
{ name = "pydantic", specifier = ">=2.9.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.6.0" },
|
{ name = "pydantic-settings", specifier = ">=2.6.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||||
|
{ name = "pytz", specifier = ">=2024.2" },
|
||||||
{ name = "redis", specifier = ">=5.2.1" },
|
{ name = "redis", specifier = ">=5.2.1" },
|
||||||
{ name = "twitchio", specifier = ">=2.10.0" },
|
{ 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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.2"
|
version = "6.0.2"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue