async def _update_spotify_tokens(user: User) -> bool: """ Update the user's Spotify tokens. Returns success status """ try: exchange_data: TokenExchangeData = await get_new_access_token( user.spotifyRefreshToken, GrantType.REFRESH_TOKEN) except SpotifyApiError as err: LOGGER.warning( "Exiting update loop. Could not refresh Spotify token for " "user %s: %s", user.id, err, ) err_dict = err.response_json() if err_dict.get("error_description") == "Refresh token revoked": LOGGER.warning( "Deleting user %s as their Spotify refresh token is revoked " "and we have no way to recover :(", user.id, ) await user.delete() return False await user.update( spotifyExpiresAt=calc_spotify_expiry(exchange_data.expires_in), spotifyAccessToken=exchange_data.access_token, spotifyRefreshToken=exchange_data.refresh_token or "", updatedAt=datetime.now(timezone.utc), ) return True
async def _throttled_update_user(user, sem): async with sem: # semaphore limits num of simultaneous updated try: await _update_user(user) except (httpx.HTTPError, sqlalchemy.exc.SQLAlchemyError) as err: LOGGER.error("Fatal error in update loop for user %s: %s", user.id, err)
async def shutdown(): """ Shutdown actions """ # pylint:disable=import-outside-toplevel current_task = asyncio.current_task() other_tasks = [t for t in asyncio.all_tasks() if t is not current_task] LOGGER.info("Cancelling %s outstanding tasks", len(other_tasks)) for task in other_tasks: task.cancel() await DATABASE.disconnect() await asyncio.gather(*other_tasks, return_exceptions=True)
async def slack_grant(request: Request): """ Slack grant redirect (request access token) """ # TODO include state data grant_args = { "client_id": SETTINGS.slack_client_id, "scope": " ".join(AUTHORIZE_SCOPES), "redirect_uri": SETTINGS.slack_redirect_uri, } LOGGER.debug("Slack grant redirect initiated [%s]", request.client) return RedirectResponse( f"{AUTHORIZE_URI}?{urlencode(grant_args, doseq=True)}")
async def worker_entrypoint() -> None: """ The entrypoint for the worker. Currently a stub """ # pylint:disable=protected-access DATABASE._connection_context = ContextVar("connection_context") # Hack :( sem = asyncio.Semaphore(SETTINGS.worker_coroutines) while True: LOGGER.debug("Starting global update loop") update_tasks = [ _throttled_update_user(user=user, sem=sem) for user in await User.objects.all() ] await asyncio.gather(*update_tasks)
async def sign_out(request: Request): """ Sign out """ try: user_id = request.session["user_id"] except KeyError: LOGGER.debug("Unauthenticated user attempted to sign out [%s]", request.client) else: LOGGER.info("User %s signed out [%s]", user_id, request.client) auth.sign_out(request) return RedirectResponse("/")
async def spotify_grant(request: Request): """ Spotify grant redirect (request access & refresh tokens) """ # TODO include state data grant_args = { "client_id": SETTINGS.spotify_client_id, "response_type": "code", "scope": AUTHORIZE_SCOPES, "redirect_uri": SETTINGS.spotify_redirect_uri, } LOGGER.debug("Spotify grant redirect initiated [%s]", request.client) return RedirectResponse( f"{AUTHORIZE_URI}?{urlencode(grant_args, doseq=True)}")
async def get_or_create_from_session( session: Dict[str, Any]) -> Tuple[Optional[User], bool]: """ Get or create a user based on auth info in their session """ created = False now = datetime.now(timezone.utc) try: full_session = FullSession(**session) except ValidationError: return (None, created) try: user = await User.objects.get( slackId=full_session.slack_id, spotifyId=full_session.spotify_id, ) except orm.exceptions.MultipleMatches: LOGGER.error( "Multiple users returned for slack_id=%s, spotify_id=%s!", full_session.slack_id, full_session.spotify_id, ) user = None except orm.exceptions.NoMatch: created = True user = await User.objects.create( slackId=full_session.slack_id, slackAccessToken=full_session.slack_access_token, spotifyId=full_session.spotify_id, spotifyExpiresAt=full_session.spotify_expires_at, spotifyAccessToken=full_session.spotify_access_token, spotifyRefreshToken=full_session.spotify_refresh_token, createdAt=now, updatedAt=now, ) else: await user.update( slackAccessToken=full_session.slack_access_token, spotifyExpiresAt=full_session.spotify_expires_at, spotifyAccessToken=full_session.spotify_access_token, spotifyRefreshToken=full_session.spotify_refresh_token, updatedAt=now, ) return (user, created)
async def sign_in(request: Request) -> Optional[User]: """ Try signing in the user (creating or updated them) Returns None if the user can't be signed in. """ user, created = await get_or_create_from_session(session=request.session) if user: LOGGER.info( "User %s signed in (%s) [%s]", user.id, "created" if created else "updated", request.client, ) request.session.clear() request.session["user_id"] = user.id else: LOGGER.debug("User sign in attempt failed [%s]", request.client) return user
async def _set_user_status(user: User, user_profile_args: UserProfileArgs, status_set_last_time: bool) -> bool: """ Set the user status & update their database entry. Returns success status """ try: await set_status(user_profile_args, user.slackAccessToken) except SlackApiError as err: LOGGER.warning( "Exiting update loop. Could not set status for user %s: " "%s", user.id, err, ) return False await user.update( statusSetLastTime=status_set_last_time, updatedAt=datetime.now(timezone.utc), ) return True
async def delete_account(request: Request): """ Delete account (stop monitoring) """ try: user_id = request.session["user_id"] except KeyError: LOGGER.debug( "Unauthenticated user attempted to delete account [%s]", request.client, ) return RedirectResponse("/") try: user = await User.objects.get(id=user_id) except orm.exceptions.NoMatch: LOGGER.warning( "User %s not found in the database; cannot delete [%s]", user_id, request.client, ) else: await user.delete() LOGGER.info("User %s deleted account [%s]", user_id, request.client) auth.sign_out(request) return RedirectResponse("/")
async def spotify_grant_callback(request: Request, code: Optional[str] = None, error: Optional[str] = None): """ Spotify grant callback (receive access & refresh tokens) """ if code is None and error is None: error = f"Neither 'code' nor 'error' were provided from Spotify" if error is not None: LOGGER.warning("Spotify grant callback error: %s", error) return RedirectResponse("/") try: exchange_data: TokenExchangeData = await get_new_access_token( cast(str, code), GrantType.CODE # Checks above ensure code is str ) except SpotifyApiError as err: LOGGER.warning("%s", err) return RedirectResponse("/") try: me_data: MeData = await get_me(exchange_data.access_token) except SpotifyApiError as err: LOGGER.warning("%s", err) return RedirectResponse("/") request.session["spotify_id"] = me_data.id request.session["spotify_expires_at"] = ( datetime.now(timezone.utc) + timedelta(seconds=exchange_data.expires_in)).isoformat() request.session["spotify_access_token"] = exchange_data.access_token request.session["spotify_refresh_token"] = exchange_data.refresh_token await sign_in(request) LOGGER.debug("Spotify grant callback successful [%s]", request.client) return RedirectResponse("/")
async def _set_user_status(user: User, user_profile_args: UserProfileArgs, status_set_last_time: bool) -> bool: global UPDATE_THRESHOLD """ Set the user status & update their database entry. Returns success status """ try: current_profile = await get_status(user.slackAccessToken) if current_profile.profile.status_text == user_profile_args.status_text: return True LOGGER.info("Setting user status %s", user_profile_args) await asyncio.sleep(1) user_profile_data: UserProfileData = await set_status( user_profile_args, user.slackAccessToken) except SlackApiError as err: LOGGER.warning( "Exiting update loop. Could not set status for user %s: %s", user.id, err, ) UPDATE_THRESHOLD = datetime.now(timezone.utc) + timedelta(seconds=6) return False if not user_profile_data.ok: LOGGER.warning( "Exiting update loop. Could not set status for user %s: %s", user.id, user_profile_data.error, ) if user_profile_data.error in {"token_revoked"}: LOGGER.warning("Slack token revoked. Deleting user %s", user.id) await user.delete() return False await user.update( statusSetLastTime=status_set_last_time, updatedAt=datetime.now(timezone.utc), ) return True
async def slack_grant_callback(request: Request, code: Optional[str] = None): """ Slack grant callback (receive access token) """ if code is None: LOGGER.warning( "Spotify grant callback error: 'code' was not provided by Spotify") return RedirectResponse("/") try: exchange_data: TokenExchangeData = await get_new_access_token(code, ) except SlackApiError as err: LOGGER.warning("%s", err) return RedirectResponse("/") request.session["slack_id"] = exchange_data.user_id request.session["slack_access_token"] = exchange_data.access_token await sign_in(request) LOGGER.debug("Slack grant callback successful [%s]", request.client) return RedirectResponse("/")
async def _update_user(user: User) -> None: """ Update a single user """ global UPDATE_THRESHOLD # pylint:disable=global-statement update_threshold_delta = UPDATE_THRESHOLD - datetime.now(timezone.utc) if update_threshold_delta.total_seconds() > 0: LOGGER.debug( "Sleeping update thread for %s for %ss", user.id, update_threshold_delta.total_seconds(), ) await asyncio.sleep(update_threshold_delta.total_seconds()) # Handle Spotify token refreshes spotify_token_expired = user.spotifyExpiresAt <= datetime.now( timezone.utc) - timedelta(minutes=5) if spotify_token_expired and not user.spotifyRefreshToken: LOGGER.warning( "Deleting user %s as their Spotify token is expired and no " "refresh token is available :(", user.id, ) await user.delete() return if spotify_token_expired: LOGGER.debug("Refreshing Spotify token for user %s", user.id) update_ok = await _update_spotify_tokens(user) if not update_ok: return LOGGER.debug("Refreshing Spotify token for user %s COMPLETE", user.id) # Retrieve Spotify player status try: player: Optional[PlayerData] = await get_player(user.spotifyAccessToken ) except SpotifyApiError as err: if err.retry_after is None: LOGGER.warning( "Exiting update loop. Could not retrieve player data for user %s: " "%s", user.id, err, ) else: UPDATE_THRESHOLD = datetime.now( timezone.utc) + timedelta(seconds=err.retry_after) LOGGER.warning( "Exiting update loop. Spotify is throttling for %ss", err.retry_after, ) return if player is not None and player.item is not None and player.is_playing: user_profile_args = UserProfileArgs( status_text=_calc_status_text(player.item), status_emoji=get_custom_emoji(user, player.item), ) LOGGER.debug("Setting user status %s", user_profile_args) # TODO rm await _set_user_status(user, user_profile_args, True) elif user.statusSetLastTime: user_profile_args = UserProfileArgs(status_text="", status_emoji="") await _set_user_status(user, user_profile_args, False)
@APP.on_event("shutdown") async def shutdown(): """ Shutdown actions """ await DATABASE.disconnect() if __name__ == "__main__": logging.basicConfig(level=2, format="%(levelname)-9s %(message)s") CONFIG = uvicorn.Config( "backend.main:APP", host="0.0.0.0", port=SETTINGS.port, loop="uvloop", log_level="info", use_colors=True, ) SERVER = uvicorn.Server(config=CONFIG) # Start both the server & worker together CONFIG.setup_event_loop() LOOP = asyncio.get_event_loop() try: LOOP.run_until_complete( asyncio.gather(SERVER.serve(), worker_entrypoint())) finally: LOOP.close() LOGGER.info("Shutdown successful")