commit 45c8cc1ae00e17f81ecdd825f757cb438db8f884 Author: fz0x1 Date: Fri May 2 10:06:51 2025 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70fd073 --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +RuneLite.jar +RuneLiteClean.jar +settings.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..66ec3dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright (c) 2025 fz0x1. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b388e --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# pyscape + diff --git a/iptable.sh b/iptable.sh new file mode 100755 index 0000000..c9e11b1 --- /dev/null +++ b/iptable.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 {patch|unpatch}" + exit 1 +fi + +ACTION=$1 + +if [ "$ACTION" = "patch" ]; then + echo "setup port redirect 80 -> 5353" + sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5353 + sudo iptables -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-port 5353 + echo "Redirect established." +elif [ "$ACTION" = "unpatch" ]; then + echo "remove port redirect port 80 -> 5353" + sudo iptables -t nat -D PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5353 + sudo iptables -t nat -D OUTPUT -p tcp --dport 80 -j REDIRECT --to-port 5353 + echo "Redirect removed." +else + echo "Invalid parameter. Use: $0 {patch|unpatch}" + exit 1 +fi diff --git a/main.py b/main.py new file mode 100644 index 0000000..0738c05 --- /dev/null +++ b/main.py @@ -0,0 +1,173 @@ +import time +import sys +import json +import logging +import pyscape +import pyscape.utils +import pyscape.ui +import pyscape.game + +# Настройка логирования +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + + +def launch_game_with_token(token, id_token): + """ + Запускает игру с переданным токеном и id_token. + """ + try: + id_token_payload = pyscape.parse_id_token(id_token) + except Exception as e: + raise e + + # Получаем данные пользователя по sub из id_token + sub = id_token_payload["sub"] + user = pyscape.get_user_details(sub, token["access_token"]) + logging.info("You are logged in as: {}#{}".format(user.display_name, user.suffix)) + + # Проверка поля login_provider в JWT + if ( + "login_provider" not in id_token_payload + or id_token_payload["login_provider"] != pyscape.LOGIN_PROVIDER + ): + logging.info( + "No known login_provider found in JWT token, using standard login..." + ) + + code, new_id_token = pyscape.standard_login(id_token) + # tup предполагается как кортеж, где второй элемент – обновлённый id_token + pyscape.utils.write_to_config_file(new_id_token, "id_token") + + session_id = pyscape.get_game_session(new_id_token) + accounts = pyscape.get_accounts(session_id) + logging.info("Found {} accounts".format(len(accounts))) + + try: + account = pyscape.ui.get_chosen_account(user, accounts) + except pyscape.NoAccountChosenError: + logging.info("No account was chosen") + return + + pyscape.game.launch_game(session_id, account) + + +def handle_social_auth(payload, refresh: bool = False): + """ + Обрабатывает social_auth: обменивает код на токен, сохраняет данные и запускает игру. + """ + if not refresh: + try: + token = pyscape.exchange(payload["code"]) + except Exception as e: + raise e + else: + print("REFRESH") + token = payload + + # Если метод extra недоступен, возможно, токен представлен как словарь + id_token = token.get("id_token") + if not id_token: + raise Exception("id_token not found in token response") + + pyscape.utils.write_to_config_file(id_token, "id_token") + + # Вычисляем время истечения токена как текущее время + время жизни токена (в секундах) + expiry = int(time.time() + token["expires_in"] * 1000) + token["expiry"] = expiry + + # Сериализуем OAuth токен в JSON + token_json = json.dumps(token) + pyscape.utils.write_to_config_file(token_json, "token") + launch_game_with_token(token, id_token) + + +def get_cached_tokens(): + """ + Читает сохранённые токены из конфигурационного файла. + Если срок действия токена истёк (или почти истёк), возбуждает исключение. + """ + token_json = pyscape.utils.read_from_config_file("token") + try: + token = json.loads(token_json) + except json.JSONDecodeError as e: + raise Exception("Invalid token JSON") from e + + import datetime + + current_time = datetime.datetime.now() + + # Если до истечения токена осталось менее 30 минут, считаем его недействительным. + if ( + datetime.datetime.fromtimestamp(token["expiry"]) - current_time + ) <= datetime.timedelta(minutes=30): + raise Exception("cached token has expired") + + id_token = pyscape.utils.read_from_config_file("id_token") + return token, id_token + + +def handle_regular_launch(): + """ + Пытается использовать сохранённые токены для запуска игры, + иначе инициирует стандартный процесс аутентификации. + """ + try: + token, id_token = get_cached_tokens() + try: + refresh_result = pyscape.refresh_token(token["refresh_token"]) + handle_social_auth(refresh_result, True) + except Exception as e: + print(str(e)) + pyscape.login() + return + except Exception as e: + logging.info( + f"Could not load cached tokens, initiating regular login flow: {e}" + ) + pyscape.login() + return + + logging.info("Loaded cached tokens") + try: + launch_game_with_token(token, id_token) + except Exception as e: + print(e) + logging.info( + "Could not login with cached tokens, initiating regular login flow" + ) + pyscape.login() + + +def main(): + """ + Основная функция приложения. + Если аргументы командной строки не заданы – используется регулярный запуск, + иначе выполняется разбор intent из payload. + """ + # Если отсутствуют дополнительные аргументы, инициируем регулярный запуск. + if len(sys.argv) < 2: + try: + handle_regular_launch() + except Exception as e: + logging.error(e) + sys.exit(1) + sys.exit(0) + + # Если передан аргумент, то он рассматривается как payload для intent. + payload = pyscape.utils.parse_intent_payload(sys.argv[1]) + if "intent" not in payload: + logging.fatal("No intent found in payload: {}".format(payload)) + sys.exit(1) + + if payload["intent"] == "social_auth": + try: + handle_social_auth(payload) + except Exception as e: + pyscape.utils.die(e) + raise + + input("Press Enter to exit...") + + +if __name__ == "__main__": + main() diff --git a/pyscape/__init__.py b/pyscape/__init__.py new file mode 100644 index 0000000..def17a4 --- /dev/null +++ b/pyscape/__init__.py @@ -0,0 +1,11 @@ +from .req import ( + login, + exchange, + parse_id_token, + get_user_details, + standard_login, + get_game_session, + get_accounts, + LOGIN_PROVIDER, + refresh_token, +) diff --git a/pyscape/game.py b/pyscape/game.py new file mode 100644 index 0000000..ca0ebdd --- /dev/null +++ b/pyscape/game.py @@ -0,0 +1,40 @@ +import sys +import os +import subprocess + + +def launch_game(session_id: str, account) -> None: + """ + Запускает игру с заданной сессией и информацией об аккаунте. + + Параметры: + session_id (str): идентификатор сессии. + account: объект с атрибутами account_id и display_name. + Например, экземпляр класса Account с соответствующими полями. + """ + # Получаем путь к игре из переменной окружения, если она не задана, используем значение по умолчанию. + # game = os.getenv("PYSCAPE_GAME_PATH", "/home/fz0x1/projects/pyscape/RuneLite.jar") + game = os.getenv( + "PYSCAPE_GAME_PATH", "/home/fz0x1/projects/pyscape/RuneLiteClean.jar" + ) + + # Копируем текущее окружение и добавляем дополнительные переменные + env = os.environ.copy() + env.update( + { + "JX_SESSION_ID": session_id, + "JX_CHARACTER_ID": account.account_id, + "JX_DISPLAY_NAME": account.display_name, + } + ) + if game.endswith(".jar"): + subprocess.Popen( + ["java", "-jar", game], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + subprocess.Popen([game], env=env) + + sys.exit(1) diff --git a/pyscape/req.py b/pyscape/req.py new file mode 100644 index 0000000..9a92f4c --- /dev/null +++ b/pyscape/req.py @@ -0,0 +1,382 @@ +import base64 +import json +import logging +import threading +import queue +import hashlib +import secrets +from urllib.parse import urlencode +import webbrowser +import requests +import uuid +from flask import Flask, request, Response + +# --------------------------- +# Здесь предполагается, что у вас есть реализации следующих функций в модуле utils: +# make_random_state() - генерирует случайное состояние (строку) +# write_to_config_file(content, filename) - сохраняет строку в файл (например, в папке конфигурации) +# read_from_config_file(filename) - читает строку из файла конфигурации +# Если их нет, замените их на свои реализации. +# --------------------------- +from .utils import make_random_state, write_to_config_file, read_from_config_file + +# Константы (аналогичные Go‑константам) +CLIENT_ID = "com_jagex_auth_desktop_launcher" +STANDARD_LOGIN_CLIENT_ID = "1fddee4e-b100-4f4e-b2b0-097f9088f9d2" +LOGIN_PROVIDER = "runescape" +AUTH_URL = "https://account.jagex.com/oauth2/auth" +TOKEN_URL = "https://account.jagex.com/oauth2/token" +REDIRECT_URL = "https://secure.runescape.com/m=weblogin/launcher-redirect" +API_URL = "https://api.jagex.com/v1" +PROFILE_API_URL = "https://secure.jagex.com/rs-profile/v1" +SHIELD_URL = "https://auth.jagex.com/shield/oauth/token" +GAME_SESSION_API_URL = "https://auth.jagex.com/game-session/v1" +OSRS_BASIC_AUTH_HEADER = "Basic Y29tX2phZ2V4X2F1dGhfZGVza3RvcF9vc3JzOnB1YmxpYw==" + + +# Аналог класса-кортежа Tuple[T1, T2] +class Tuple: + def __init__(self, first, second): + self.first = first + self.second = second + + +# --------------------------- +# Вспомогательные функции для PKCE +# --------------------------- +def make_random_verifier(length: int = 43) -> str: + """ + Генерирует случайную строку для PKCE (code verifier) + с использованием допустимых символов. + """ + allowed_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + result = [] + for _ in range(length): + # Генерируем случайное 32-битное число, аналогично crypto.getRandomValues(new Uint32Array(43)) + rand_int = secrets.randbits(32) + result.append(allowed_chars[rand_int % len(allowed_chars)]) + return "".join(result) + + +def compute_code_challenge(verifier: str) -> str: + """ + Вычисляет code challenge по алгоритму S256: + - SHA-256 от verifier, + - Base64 URL-safe кодирование без завершающих '='. + """ + verifier_bytes = verifier.encode("utf-8") + digest = hashlib.sha256(verifier_bytes).digest() + # base64.urlsafe_b64encode заменяет '+' на '-' и '/' на '_' + code_challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") + return code_challenge + + +# --------------------------- +# OAuth2 конфигурация +# --------------------------- +def get_auth_config() -> dict: + """ + Возвращает словарь с настройками OAuth2. + """ + return { + "client_id": CLIENT_ID, + "client_secret": "", + "scopes": ["openid", "offline", "gamesso.token.create", "user.profile.read"], + "auth_url": AUTH_URL, + "token_url": TOKEN_URL, + "redirect_url": REDIRECT_URL, + } + + +# --------------------------- +# Функция Login - инициирует OAuth2 авторизацию с PKCE +# --------------------------- +def login() -> None: + """ + Инициирует OAuth2-авторизацию: + – генерирует PKCE verifier и вычисляет code challenge, + – генерирует случайное состояние, + – сохраняет исходный verifier и состояние, + – формирует URL авторизации и открывает его в браузере. + """ + config = get_auth_config() + verifier = make_random_verifier() # Исходный verifier + challenge = compute_code_challenge( + verifier + ) # Вычисляем code challenge по алгоритму S256 + state = make_random_state() + + params = { + "client_id": config["client_id"], + "redirect_uri": config["redirect_url"], + "response_type": "code", + "scope": " ".join(config["scopes"]), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + url = config["auth_url"] + "?" + urlencode(params) + + write_to_config_file(state, "state") + # Сохраняем исходный verifier (не challenge!) + write_to_config_file(verifier, "verifier") + webbrowser.open(url) + + +def refresh_token(refresh_token: str): + config = get_auth_config() + data = { + "client_id": config["client_id"], + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + response = requests.post(config["token_url"], data=data) + if response.status_code != 200: + raise Exception("Refresh token update failed: " + response.text) + token = response.json() + return token + + +# --------------------------- +# Функция Exchange - обмен authorization code на токен +# --------------------------- +def exchange(code: str) -> dict: + """ + Обменивает authorization code на токен. + Читает ранее сохранённый verifier и отправляет POST-запрос к TOKEN_URL. + """ + config = get_auth_config() + verifier = read_from_config_file("verifier") + data = { + "client_id": config["client_id"], + "grant_type": "authorization_code", + "code": code, + "redirect_uri": config["redirect_url"], + "code_verifier": verifier, + } + response = requests.post(config["token_url"], data=data) + if response.status_code != 200: + raise Exception("Token exchange failed: " + response.text) + token = response.json() + return token + + +# --------------------------- +# Функция ParseIdToken - разбирает JWT id_token +# --------------------------- +def parse_id_token(id_token: str) -> dict: + """ + Разбирает JWT id_token: + – декодирует заголовок и полезную нагрузку, + – проверяет, что заголовок содержит тип 'JWT', + – возвращает полезную нагрузку. + """ + parts = id_token.split(".") + if len(parts) != 3: + raise Exception(f"malformed id_token: {len(parts)} sections, expected 3") + + def pad_base64(b: str) -> str: + return b + "=" * (-len(b) % 4) + + # Декодирование заголовка + header_bytes = base64.urlsafe_b64decode(pad_base64(parts[0])) + header = json.loads(header_bytes) + if header.get("typ") != "JWT": + raise Exception(f"bad id_token header: typ {header.get('typ')}, expected JWT") + + # Декодирование полезной нагрузки + payload_bytes = base64.urlsafe_b64decode(pad_base64(parts[1])) + payload = json.loads(payload_bytes) + logging.info("id_token payload: %s", payload) + return payload + + +# --------------------------- +# Функция StandardLogin - стандартный процесс логина через Flask-сервер +# --------------------------- +def standard_login(id_token: str) -> tuple: + """ + Реализует стандартный процесс логина: + – меняет client_id, redirect_url и scopes, + – формирует URL авторизации с дополнительными параметрами, + – поднимает Flask-сервер для получения кода и нового id_token, + – возвращает кортеж (code, id_token). + """ + config = get_auth_config() + config["client_id"] = STANDARD_LOGIN_CLIENT_ID + config["redirect_url"] = "http://localhost" + config["scopes"] = ["openid", "offline"] + + state = make_random_state() + params = { + "client_id": config["client_id"], + "redirect_uri": config["redirect_url"], + "response_type": "id_token code", + "scope": " ".join(config["scopes"]), + "state": state, + "id_token_hint": id_token, + "nonce": str(uuid.uuid4()), + "prompt": "consent", + } + url = config["auth_url"] + "?" + urlencode(params) + + # Очередь для передачи результата из Flask в основной поток + result_queue = queue.Queue() + + app = Flask(__name__) + + @app.route("/") + def index(): + # Отправляем страницу с JavaScript, который извлекает фрагмент URL + js = """ + + """ + return Response(js, mimetype="text/html") + + @app.route("/process_fragment") + def process_fragment(): + code = request.args.get("code") + new_id_token = request.args.get("id_token") + # print(code, new_id_token) + result_queue.put((code, new_id_token)) + return "Received" + + def run_flask(): + port = 8080 + logging.info("Binding to port: %d", port) + app.run(host="0.0.0.0", port=port, debug=False) + + flask_thread = threading.Thread(target=run_flask, daemon=True) + flask_thread.start() + + webbrowser.open(url) + + # Ждем результата из очереди + code, new_id_token = result_queue.get() + # Здесь можно добавить остановку Flask-сервера, если требуется + return code, new_id_token + + +# --------------------------- +# Функция GetGameSession - получение игровой сессии +# --------------------------- +def get_game_session(id_token: str) -> str: + """ + Проверяет корректность id_token и отправляет POST-запрос к GAME_SESSION_API_URL/sessions для получения sessionId. + """ + # Валидация id_token + parse_id_token(id_token) + + url = GAME_SESSION_API_URL + "/sessions" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Host": "auth.jagex.com", + } + body = {"idToken": id_token} + response = requests.post(url, json=body, headers=headers) + if response.status_code != 200: + raise Exception("getGameSession failed: " + response.text) + data = response.json() + session_id = data.get("sessionId") + if not isinstance(session_id, str): + raise Exception(f"getGameSession: did not find 'sessionId', data: {data}") + return session_id + + +# --------------------------- +# Функция GetUserDetails - получение данных пользователя +# --------------------------- +class UserDetails: + def __init__(self, display_name: str, id: str, suffix: str, user_id: str): + self.display_name = display_name + self.id = id + self.suffix = suffix + self.user_id = user_id + + +def get_user_details(sub: str, access_token: str) -> UserDetails: + """ + Получает данные пользователя по sub, отправляя GET-запрос к API. + """ + url = f"{API_URL}/users/{sub}/displayName" + headers = {"Authorization": "Bearer " + access_token} + response = requests.get(url, headers=headers) + if response.status_code != 200: + raise Exception("could not get user details: " + response.text) + info = response.json() + return UserDetails( + display_name=info.get("displayName"), + id=info.get("id"), + suffix=info.get("suffix"), + user_id=info.get("userId"), + ) + + +# --------------------------- +# Функция GetAccounts - получение списка аккаунтов +# --------------------------- +class Account: + def __init__(self, account_id: str, display_name: str, user_hash: str): + self.account_id = account_id + self.display_name = display_name + self.user_hash = user_hash + + +def get_accounts(session_id: str) -> list: + """ + Получает список аккаунтов, отправляя GET-запрос к GAME_SESSION_API_URL/accounts. + """ + url = f"{GAME_SESSION_API_URL}/accounts" + headers = { + "Accept": "application/json", + "Authorization": "Bearer " + session_id, + } + response = requests.get(url, headers=headers) + if response.status_code != 200: + raise Exception("getAccounts error: " + response.text) + accounts_json = response.json() + accounts = [] + for acc in accounts_json: + accounts.append( + Account( + account_id=acc.get("accountId"), + display_name=acc.get("displayName"), + user_hash=acc.get("userHash"), + ) + ) + return accounts + + +# --------------------------- +# Функция getShieldTokens - обмен токена на shield-токен +# --------------------------- +def get_shield_tokens(access_token: str) -> dict: + """ + Отправляет запрос на обмен токена (token_exchange) к SHIELD_URL. + """ + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": OSRS_BASIC_AUTH_HEADER, + } + params = { + "token": access_token, + "grant_type": "token_exchange", + "scope": "gamesso.token.create", + } + response = requests.post(SHIELD_URL, data=params, headers=headers) + if response.status_code != 200: + raise Exception("getShieldTokens failed: " + response.text) + data = response.json() + logging.info("getShieldToken: %s", response.text) + return data diff --git a/pyscape/ui.py b/pyscape/ui.py new file mode 100644 index 0000000..41d315e --- /dev/null +++ b/pyscape/ui.py @@ -0,0 +1,112 @@ +import curses +from typing import List + + +class NoAccountChosenError(Exception): + def __str__(self): + return "no account was chosen" + + +# Пример класса для аккаунта (адаптируйте под вашу реализацию) +class Account: + def __init__(self, account_id: str, display_name: str): + self.account_id = account_id + self.display_name = display_name + + +# Пример класса для данных пользователя +class UserDetails: + def __init__(self, display_name: str, suffix: str): + self.display_name = display_name + self.suffix = suffix + + +def curses_account_selection( + stdscr, user: UserDetails, accounts: List[Account] +) -> Account: + # Настройка curses + curses.curs_set(0) # скрываем курсор + stdscr.nodelay(False) + stdscr.keypad(True) + + # Инициализация цветовой пары (белый текст на синем фоне) + curses.start_color() + curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) + highlight_color = curses.color_pair(1) + + current_index = 0 + num_accounts = len(accounts) + + while True: + stdscr.clear() + height, width = stdscr.getmaxyx() + + # Заголовок с данными пользователя + title = f"You are logged in as: {user.display_name}#{user.suffix}" + stdscr.addstr(1, 2, title) + + # Инструкция + instruction = ( + "Use UP/DOWN to select an account, Enter to confirm, 'q' to cancel." + ) + stdscr.addstr(3, 2, instruction) + + # Вывод списка аккаунтов + for idx, acc in enumerate(accounts): + line = f"{idx + 1}. {acc.display_name}" + y = 5 + idx + x = 4 + if idx == current_index: + stdscr.attron(highlight_color) + stdscr.addstr(y, x, line) + stdscr.attroff(highlight_color) + else: + stdscr.addstr(y, x, line) + + stdscr.refresh() + + key = stdscr.getch() + + if key in [curses.KEY_UP, ord("k")]: + current_index = (current_index - 1) % num_accounts + elif key in [curses.KEY_DOWN, ord("j")]: + current_index = (current_index + 1) % num_accounts + elif key in [10, 13]: # Enter + return accounts[current_index] + elif key in [ord("q"), 27]: # q или Esc для выхода + raise NoAccountChosenError() + + +def get_chosen_account(user: UserDetails, accounts: List[Account]) -> Account: + """ + Запускает TUI для выбора аккаунта. + + :param user: Объект с информацией о пользователе (например, display_name и suffix) + :param accounts: Список объектов Account + :return: Выбранный объект Account + :raises NoAccountChosenError: Если выбор не был произведён + """ + try: + chosen = curses.wrapper(curses_account_selection, user, accounts) + return chosen + except Exception as e: + raise NoAccountChosenError() from e + + +# Пример использования: +if __name__ == "__main__": + # Создаем тестовые данные + user = UserDetails("PlayerOne", "1234") + accounts = [ + Account("acc1", "Warrior"), + Account("acc2", "Mage"), + Account("acc3", "Archer"), + ] + + try: + chosen_account = get_chosen_account(user, accounts) + print( + f"Selected account: {chosen_account.display_name} (ID: {chosen_account.account_id})" + ) + except NoAccountChosenError as e: + print(str(e)) diff --git a/pyscape/utils.py b/pyscape/utils.py new file mode 100644 index 0000000..b291aa4 --- /dev/null +++ b/pyscape/utils.py @@ -0,0 +1,93 @@ +import os +import sys +import json +import logging +import random +import string +from typing import Any, Dict, NamedTuple + + +# Для удобства можно определить кортеж, аналог Tuple[T1, T2] в Go. +class Tuple(NamedTuple): + first: Any + second: Any + + +def make_random_state() -> str: + """ + Генерирует случайную строку длиной 12 символов, + состоящую из латинских букв (заглавных и строчных). + """ + charset = string.ascii_letters + length = 12 + return "".join(random.choice(charset) for _ in range(length)) + + +def get_config_file(filename: str) -> str: + """ + Формирует путь до файла конфигурации, расположенного в пользовательской папке. + По умолчанию используется директория "~/.config/pyscape". + """ + # Получаем директорию конфигурации пользователя. + config_dir = os.path.expanduser("~/.config") + return os.path.join(config_dir, "pyscape", filename) + + +def write_to_config_file(content: str, filename: str) -> None: + """ + Записывает content в файл конфигурации, создавая необходимые каталоги. + """ + config_file = get_config_file(filename) + os.makedirs(os.path.dirname(config_file), exist_ok=True) + print("content: ", content) + try: + with open(config_file, "w", encoding="utf-8") as f: + f.write(content) + except Exception as e: + print(str(e)) + raise + + +def read_from_config_file(filename: str) -> str: + """ + Читает и возвращает содержимое файла конфигурации. + """ + config_file = get_config_file(filename) + with open(config_file, "r", encoding="utf-8") as f: + contents = f.read() + return contents + + +def parse_intent_payload(payload: str) -> Dict[str, str]: + """ + Разбирает строку payload формата "jagex:key1=value1,key2=value2" + и возвращает словарь с полученными парами ключ-значение. + """ + result: Dict[str, str] = {} + parts = payload.split(":") + if parts[0] != "jagex" or len(parts) != 2: + logging.fatal("invalid payload: %s", payload) + sys.exit(1) + pairs = parts[1].split(",") + for pair in pairs: + key_value = pair.split("=") + if len(key_value) == 2: + result[key_value[0]] = key_value[1] + return result + + +def parse_json(buffer: bytes) -> Dict[str, Any]: + """ + Десериализует JSON-данные из байтов в словарь. + """ + return json.loads(buffer.decode("utf-8")) + + +def die(err: Exception) -> None: + """ + Выводит ошибку, ждёт нажатия Enter и завершает работу программы. + """ + logging.error(err) + print("Press Enter to exit...") + input() + sys.exit(1)