diff --git a/pyproject.toml b/pyproject.toml index 88791a82..27278001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [tool.poetry] name = "royalpack" - version = "5.13.1" + version = "5.13.2" description = "A Royalnet command pack for the Royal Games community" authors = ["Stefano Pigozzi "] license = "AGPL-3.0+" @@ -23,8 +23,8 @@ riotwatcher = "^3.0.0" royalspells = "^3.2" steam = "*" -sqlalchemy = "^1.3.18" -bcrypt = "^3.1.7" + sqlalchemy = "^1.3.18" + bcrypt = "^3.1.7" [tool.poetry.dependencies.royalnet] version = "~5.10.4" diff --git a/royalpack/commands/__init__.py b/royalpack/commands/__init__.py index 4088121c..eb4d5a1b 100644 --- a/royalpack/commands/__init__.py +++ b/royalpack/commands/__init__.py @@ -38,6 +38,7 @@ from .steampowered import SteampoweredCommand from .treasure import TreasureCommand from .trivia import TriviaCommand from .userinfo import UserinfoCommand +from .osu import OsuCommand # Enter the commands of your Pack here! available_commands = [ @@ -80,6 +81,7 @@ available_commands = [ TreasureCommand, TriviaCommand, UserinfoCommand, + OsuCommand, ] # Don't change this, it should automatically generate __all__ diff --git a/royalpack/commands/osu.py b/royalpack/commands/osu.py new file mode 100644 index 00000000..dff74519 --- /dev/null +++ b/royalpack/commands/osu.py @@ -0,0 +1,125 @@ +from typing import * +import itsdangerous +import aiohttp + +from royalnet.backpack import tables as rbt +import royalnet.commands as rc +import royalnet.utils as ru + +from .abstract.linker import LinkerCommand +from ..types import Updatable +from ..tables import Osu +from ..stars.api_auth_login_osu import ApiAuthLoginOsuStar + + +class OsuCommand(LinkerCommand): + name = "osu" + + description = "Connetti e sincronizza il tuo account di osu!" + + @property + def client_id(self): + return self.config[self.name]['client_id'] + + @property + def client_secret(self): + return self.config[self.name]['client_secret'] + + @property + def base_url(self): + return self.config['base_url'] + + @property + def secret_key(self): + return self.config['secret_key'] + + async def get_updatables_of_user(self, session, user: rbt.User) -> List[Osu]: + return user.osu + + async def get_updatables(self, session) -> List[Osu]: + return await ru.asyncify(session.query(self.alchemy.get(Osu)).all) + + async def create(self, + session, + user: rbt.User, + args: rc.CommandArgs, + data: Optional[rc.CommandData] = None) -> Optional[Osu]: + serializer = itsdangerous.URLSafeSerializer(self.secret_key, salt="osu") + # TODO: Ensure the chat the link is being sent in is secure!!! + await data.reply("🔑 [b]Login necessario[/b]\n" + f"[url=https://osu.ppy.sh/oauth/authorize" + f"?client_id={self.client_id}" + f"&redirect_uri={self.base_url}{ApiAuthLoginOsuStar.path}" + f"&response_type=code" + f"&state={serializer.dumps(user.uid)}]" + f"Connetti account di osu! a {user.username}" + f"[/url]") + return None + + async def update(self, session, obj: Osu, change: Callable[[str, Any], Awaitable[None]]): + await obj.refresh_if_expired(client_id=self.client_id, + client_secret=self.client_secret, + base_url=self.base_url, + path=ApiAuthLoginOsuStar.path) + async with aiohttp.ClientSession(headers={"Authorization": f"Bearer {obj.access_token}"}) as session: + async with session.get("https://osu.ppy.sh/api/v2/me/osu") as response: + m = await response.json() + obj.avatar_url = m["avatar_url"] + obj.username = m["username"] + if "statistics" in m: + await change("standard_pp", m["statistics"].get("pp")) + async with session.get("https://osu.ppy.sh/api/v2/me/taiko") as response: + m = await response.json() + if "statistics" in m: + await change("taiko_pp", m["statistics"].get("pp")) + async with session.get("https://osu.ppy.sh/api/v2/me/fruits") as response: + m = await response.json() + if "statistics" in m: + await change("catch_pp", m["statistics"].get("pp")) + async with session.get("https://osu.ppy.sh/api/v2/me/mania") as response: + m = await response.json() + if "statistics" in m: + await change("mania_pp", m["statistics"].get("pp")) + + async def on_increase(self, session, obj: Osu, attribute: str, old: Any, new: Any) -> None: + if attribute == "standard_pp": + await self.notify(f"📈 [b]{obj.user}[/b] è salito a [b]{new:.0f}pp[/b] su [i]osu![/i]! Congratulazioni!") + elif attribute == "taiko_pp": + await self.notify(f"📈 [b]{obj.user}[/b] è salito a [b]{new:.0f}pp[/b] su [i]osu!taiko[/i]! Congratulazioni!") + elif attribute == "catch_pp": + await self.notify(f"📈 [b]{obj.user}[/b] è salito a [b]{new:.0f}pp[/b] su [i]osu!catch[/i]! Congratulazioni!") + elif attribute == "mania_pp": + await self.notify(f"📈 [b]{obj.user}[/b] è salito a [b]{new:.0f}pp[/b] su [i]osu!mania[/i]! Congratulazioni!") + + async def on_unchanged(self, session, obj: Osu, attribute: str, old: Any, new: Any) -> None: + pass + + async def on_decrease(self, session, obj: Osu, attribute: str, old: Any, new: Any) -> None: + if attribute == "standard_pp": + await self.notify(f"📉 [b]{obj.user}[/b] è sceso a [b]{new:.0f}pp[/b] su [i]osu![/i].") + elif attribute == "taiko_pp": + await self.notify(f"📉 [b]{obj.user}[/b] è sceso a [b]{new:.0f}pp[/b] su [i]osu!taiko[/i].") + elif attribute == "catch_pp": + await self.notify(f"📉 [b]{obj.user}[/b] è sceso a [b]{new:.0f}pp[/b] su [i]osu!catch[/i].") + elif attribute == "mania_pp": + await self.notify(f"📉 [b]{obj.user}[/b] è sceso a [b]{new:.0f}pp[/b] su [i]osu!mania[/i].") + + async def on_first(self, session, obj: Osu, attribute: str, old: None, new: Any) -> None: + if attribute == "standard_pp": + await self.notify(f"⭐️ [b]{obj.user}[/b] ha guadagnato i suoi primi [b]{new:.0f}pp[/b] su [i]osu![/i]!") + elif attribute == "taiko_pp": + await self.notify(f"⭐️ [b]{obj.user}[/b] ha guadagnato i suoi primi [b]{new:.0f}pp[/b] su [i]osu!taiko[/i]!") + elif attribute == "catch_pp": + await self.notify(f"⭐️ [b]{obj.user}[/b] ha guadagnato i suoi primi [b]{new:.0f}pp[/b] su [i]osu!catch[/i]!") + elif attribute == "mania_pp": + await self.notify(f"⭐️ [b]{obj.user}[/b] ha guadagnato i suoi primi [b]{new:.0f}pp[/b] su [i]osu!mania[/i]!") + + async def on_reset(self, session, obj: Osu, attribute: str, old: Any, new: None) -> None: + if attribute == "standard_pp": + await self.notify(f"⬜️ [b]{obj.user}[/b] non è più classificato su [i]osu![/i].") + elif attribute == "taiko_pp": + await self.notify(f" ⬜️[b]{obj.user}[/b] non è più classificato su [i]osu!taiko[/i].") + elif attribute == "catch_pp": + await self.notify(f"⬜️ [b]{obj.user}[/b] non è più classificato su [i]osu!catch[/i].") + elif attribute == "mania_pp": + await self.notify(f"⬜️ [b]{obj.user}[/b] non è più classificato su [i]osu!mania[/i].") diff --git a/royalpack/stars/__init__.py b/royalpack/stars/__init__.py index 9676dbf8..07a22617 100644 --- a/royalpack/stars/__init__.py +++ b/royalpack/stars/__init__.py @@ -13,6 +13,7 @@ from .api_cvstats_avg import ApiCvstatsAvgStar from .api_user_ryg import ApiUserRygStar from .api_user_ryg_list import ApiUserRygListStar from .api_user_avatar import ApiUserAvatarStar +from .api_auth_login_osu import ApiAuthLoginOsuStar # Enter the PageStars of your Pack here! available_page_stars = [ @@ -30,6 +31,7 @@ available_page_stars = [ ApiUserRygStar, ApiUserRygListStar, ApiUserAvatarStar, + ApiAuthLoginOsuStar, ] # Don't change this, it should automatically generate __all__ diff --git a/royalpack/stars/api_auth_login_osu.py b/royalpack/stars/api_auth_login_osu.py new file mode 100644 index 00000000..ac3ba1a0 --- /dev/null +++ b/royalpack/stars/api_auth_login_osu.py @@ -0,0 +1,99 @@ +import royalnet.utils as ru +import royalnet.backpack.tables as rbt +import royalnet.constellation.api as rca +import royalnet.constellation.api.apierrors as rcae +import itsdangerous +import aiohttp +import aiohttp.client_exceptions +import datetime +from ..types import oauth_refresh +from ..tables import Osu, FiorygiTransaction + + +class ApiAuthLoginOsuStar(rca.ApiStar): + path = "/api/auth/login/osu/v1" + + parameters = { + "get": { + "code": "The code returned by the osu! API.", + "state": "(Optional) The state payload generated by the osu! command to link a new account. " + "If missing, just login." + } + } + + auth = { + "get": False, + } + + tags = ["auth"] + + @property + def client_id(self): + return self.config['osu']['client_id'] + + @property + def client_secret(self): + return self.config['osu']['client_secret'] + + @property + def base_url(self): + return self.config['base_url'] + + @property + def secret_key(self): + return self.config['secret_key'] + + @rca.magic + async def get(self, data: rca.ApiData) -> ru.JSON: + """Login to Royalnet with your osu! account.""" + OsuT = self.alchemy.get(Osu) + TokenT = self.alchemy.get(rbt.Token) + + code = data.str("code") + state = data.str("state", optional=True) + + if state is not None: + serializer = itsdangerous.URLSafeSerializer(self.config["secret_key"], salt="osu") + uid = serializer.loads(state) + user = await rbt.User.find(self.alchemy, data.session, uid) + else: + user = None + + try: + t = await oauth_refresh(url="https://osu.ppy.sh/oauth/token", + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri=f"{self.base_url}{self.path}", + refresh_code=code) + except aiohttp.client_exceptions.ClientResponseError: + raise rca.ForbiddenError("osu! API returned an error in the OAuth token exchange") + + async with aiohttp.ClientSession(headers={"Authorization": f"Bearer {t['access_token']}"}) as session: + async with session.get("https://osu.ppy.sh/api/v2/me/") as response: + m = await response.json() + + if user is not None: + osu = OsuT( + user=user, + access_token=t["access_token"], + refresh_token=t["refresh_token"], + expiration_date=datetime.datetime.now() + datetime.timedelta(seconds=t["expires_in"]), + osu_id=m["id"], + username=m["username"] + ) + + data.session.add(osu) + else: + osu = await ru.asyncify( + data.session.query(OsuT).filter_by(osu_id=m["id"]).all + ) + if osu is None: + raise rcae.ForbiddenError("Unknown osu! account") + user = osu.user + + token: rbt.Token = TokenT.generate(alchemy=self.alchemy, user=user, expiration_delta=datetime.timedelta(days=7)) + + data.session.add(token) + await data.session_commit() + + return token.json() diff --git a/royalpack/tables/__init__.py b/royalpack/tables/__init__.py index 6eac5697..e951bb5c 100644 --- a/royalpack/tables/__init__.py +++ b/royalpack/tables/__init__.py @@ -18,6 +18,7 @@ from .mmevents import MMEvent from .mmresponse import MMResponse from .cvstats import Cvstats from .treasure import Treasure +from .osu import Osu # Enter the tables of your Pack here! available_tables = [ @@ -40,6 +41,7 @@ available_tables = [ MMResponse, Cvstats, Treasure, + Osu, ] # Don't change this, it should automatically generate __all__ diff --git a/royalpack/tables/osu.py b/royalpack/tables/osu.py new file mode 100644 index 00000000..99179a7d --- /dev/null +++ b/royalpack/tables/osu.py @@ -0,0 +1,94 @@ +from typing import * +import aiohttp +import datetime +from sqlalchemy import * +from sqlalchemy.orm import relationship, backref +from sqlalchemy.ext.declarative import declared_attr + +from ..types import Updatable, oauth_refresh + + +# noinspection PyAttributeOutsideInit +class Osu(Updatable): + __tablename__ = "osu" + + @declared_attr + def user_id(self): + return Column(Integer, ForeignKey("users.uid")) + + @declared_attr + def user(self): + return relationship("User", backref=backref("osu")) + + @declared_attr + def access_token(self): + return Column(String, nullable=False) + + @declared_attr + def refresh_token(self): + return Column(String, nullable=False) + + @declared_attr + def expiration_date(self): + return Column(DateTime, nullable=False) + + @declared_attr + def osu_id(self): + return Column(Integer, primary_key=True) + + @declared_attr + def username(self): + return Column(String, nullable=False) + + @declared_attr + def avatar_url(self): + return Column(String, nullable=False) + + @declared_attr + def standard_pp(self): + return Column(Float) + + @declared_attr + def taiko_pp(self): + return Column(Float) + + @declared_attr + def catch_pp(self): + return Column(Float) + + @declared_attr + def mania_pp(self): + return Column(Float) + + async def refresh(self, *, client_id, client_secret, base_url, path): + j = await oauth_refresh(url="https://osu.ppy.sh/oauth/token", + client_id=client_id, + client_secret=client_secret, + redirect_uri=f"{base_url}{path}", + refresh_code=self.refresh_token) + self.access_token = j["access_token"] + self.refresh_token = j["refresh_token"] + self.expiration_date = datetime.datetime.now() + datetime.timedelta(seconds=j["expires_in"]) + + async def refresh_if_expired(self, *, client_id, client_secret, base_url, path): + if datetime.datetime.now() >= self.expiration_date: + await self.refresh(client_id=client_id, client_secret=client_secret, base_url=base_url, path=path) + + def json(self) -> dict: + return { + "osu_id": self.osu_id, + "username": self.username, + "avatar_url": self.avatar_url, + "standard": { + "pp": self.standard_pp, + }, + "taiko": { + "pp": self.taiko_pp, + }, + "catch": { + "pp": self.catch_pp, + }, + "mania": { + "pp": self.mania_pp, + }, + } diff --git a/royalpack/types/__init__.py b/royalpack/types/__init__.py index ed67a89c..4e8f08a7 100644 --- a/royalpack/types/__init__.py +++ b/royalpack/types/__init__.py @@ -11,6 +11,7 @@ from .brawlhallametal import BrawlhallaMetal from .brawlhallarank import BrawlhallaRank from .pollmood import PollMood from .updatable import Updatable +from .oauth_refresh import oauth_refresh __all__ = [ @@ -28,4 +29,5 @@ __all__ = [ "BrawlhallaTier", "PollMood", "Updatable", + "oauth_refresh", ] diff --git a/royalpack/types/oauth_refresh.py b/royalpack/types/oauth_refresh.py new file mode 100644 index 00000000..62e3e6b2 --- /dev/null +++ b/royalpack/types/oauth_refresh.py @@ -0,0 +1,14 @@ +import aiohttp + + +async def oauth_refresh(*, url, client_id, client_secret, redirect_uri, refresh_code): + async with aiohttp.ClientSession() as session: + async with session.post(url, data={ + "client_id": client_id, + "client_secret": client_secret, + "code": refresh_code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri + }) as response: + j = await response.json() + return j