From a36aeef9c35d5caf03aeb782be61294e376c2854 Mon Sep 17 00:00:00 2001 From: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Date: Mon, 19 Jun 2023 11:41:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Support=20custom=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/a1c10da5704b_devices.py | 38 ++++ core/services/devices/__init__.py | 5 + core/services/devices/models.py | 23 +++ core/services/devices/repositories.py | 41 ++++ core/services/devices/services.py | 25 +++ .../apihelper/client/components/authclient.py | 5 +- modules/apihelper/client/components/verify.py | 11 +- modules/apihelper/utility/devices.py | 33 ++++ modules/apihelper/utility/helpers.py | 12 +- plugins/account/devices.py | 187 ++++++++++++++++++ utils/genshin.py | 5 +- utils/patch/genshin.py | 13 +- 12 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/a1c10da5704b_devices.py create mode 100644 core/services/devices/__init__.py create mode 100644 core/services/devices/models.py create mode 100644 core/services/devices/repositories.py create mode 100644 core/services/devices/services.py create mode 100644 modules/apihelper/utility/devices.py create mode 100644 plugins/account/devices.py diff --git a/alembic/versions/a1c10da5704b_devices.py b/alembic/versions/a1c10da5704b_devices.py new file mode 100644 index 00000000..cdeace40 --- /dev/null +++ b/alembic/versions/a1c10da5704b_devices.py @@ -0,0 +1,38 @@ +"""devices + +Revision ID: a1c10da5704b +Revises: ddcfba3c7d5c +Create Date: 2023-06-13 19:34:47.189846 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision = "a1c10da5704b" +down_revision = "ddcfba3c7d5c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "devices", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("account_id", sa.BigInteger(), nullable=True), + sa.Column("device_id", sqlmodel.AutoString(), nullable=False), + sa.Column("device_fp", sqlmodel.AutoString(), nullable=False), + sa.Column("device_name", sqlmodel.AutoString(), nullable=True), + sa.PrimaryKeyConstraint("id"), + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("devices") + # ### end Alembic commands ### diff --git a/core/services/devices/__init__.py b/core/services/devices/__init__.py new file mode 100644 index 00000000..45296b9e --- /dev/null +++ b/core/services/devices/__init__.py @@ -0,0 +1,5 @@ +"""DeviceService""" + +from core.services.devices.services import DevicesService + +__all__ = "DevicesService" diff --git a/core/services/devices/models.py b/core/services/devices/models.py new file mode 100644 index 00000000..a7a96766 --- /dev/null +++ b/core/services/devices/models.py @@ -0,0 +1,23 @@ +from typing import Optional + +from sqlmodel import SQLModel, Field, Column, Integer, BigInteger + +__all__ = ("Devices", "DevicesDataBase") + + +class Devices(SQLModel): + __table_args__ = dict(mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci") + id: Optional[int] = Field(default=None, sa_column=Column(Integer, primary_key=True, autoincrement=True)) + account_id: int = Field( + default=None, + sa_column=Column( + BigInteger(), + ), + ) + device_id: str = Field() + device_fp: str = Field() + device_name: Optional[str] = Field(default=None) + + +class DevicesDataBase(Devices, table=True): + __tablename__ = "devices" diff --git a/core/services/devices/repositories.py b/core/services/devices/repositories.py new file mode 100644 index 00000000..23153b71 --- /dev/null +++ b/core/services/devices/repositories.py @@ -0,0 +1,41 @@ +from typing import Optional + +from sqlmodel import select + +from core.base_service import BaseService +from core.dependence.database import Database +from core.services.devices.models import DevicesDataBase as Devices +from core.sqlmodel.session import AsyncSession + +__all__ = ("DevicesRepository",) + + +class DevicesRepository(BaseService.Component): + def __init__(self, database: Database): + self.engine = database.engine + + async def get( + self, + account_id: int, + ) -> Optional[Devices]: + async with AsyncSession(self.engine) as session: + statement = select(Devices).where(Devices.account_id == account_id) + results = await session.exec(statement) + return results.first() + + async def add(self, devices: Devices) -> None: + async with AsyncSession(self.engine) as session: + session.add(devices) + await session.commit() + + async def update(self, devices: Devices) -> Devices: + async with AsyncSession(self.engine) as session: + session.add(devices) + await session.commit() + await session.refresh(devices) + return devices + + async def delete(self, devices: Devices) -> None: + async with AsyncSession(self.engine) as session: + await session.delete(devices) + await session.commit() diff --git a/core/services/devices/services.py b/core/services/devices/services.py new file mode 100644 index 00000000..76bd7fc3 --- /dev/null +++ b/core/services/devices/services.py @@ -0,0 +1,25 @@ +from typing import Optional + +from core.base_service import BaseService +from core.services.devices.repositories import DevicesRepository +from core.services.devices.models import DevicesDataBase as Devices + + +class DevicesService(BaseService): + def __init__(self, devices_repository: DevicesRepository) -> None: + self._repository: DevicesRepository = devices_repository + + async def update(self, devices: Devices): + await self._repository.update(devices) + + async def add(self, devices: Devices): + await self._repository.add(devices) + + async def get( + self, + account_id: int, + ) -> Optional[Devices]: + return await self._repository.get(account_id) + + async def delete(self, devices: Devices) -> None: + return await self._repository.delete(devices) diff --git a/modules/apihelper/client/components/authclient.py b/modules/apihelper/client/components/authclient.py index acfb912b..af30fc8f 100644 --- a/modules/apihelper/client/components/authclient.py +++ b/modules/apihelper/client/components/authclient.py @@ -11,7 +11,8 @@ from qrcode.image.pure import PyPNGImage from ...logger import logger from ...models.genshin.cookies import CookiesModel -from ...utility.helpers import get_device_id, get_ds, update_device_headers +from ...utility.devices import devices_methods +from ...utility.helpers import get_device_id, get_ds __all__ = ("AuthClient",) @@ -92,7 +93,7 @@ class AuthClient: "x-rpc-app_id": "bll8iq97cem8", "User-Agent": "okhttp/4.8.0", } - update_device_headers(self.user_id, headers) + await devices_methods.update_device_headers(self.user_id, headers) app_version, client_type, ds_sign = get_ds(new_ds=True, data=data) headers["x-rpc-app_version"] = app_version headers["x-rpc-client_type"] = client_type diff --git a/modules/apihelper/client/components/verify.py b/modules/apihelper/client/components/verify.py index a6dcfeae..cde18069 100644 --- a/modules/apihelper/client/components/verify.py +++ b/modules/apihelper/client/components/verify.py @@ -4,7 +4,8 @@ import time from typing import Dict, Optional from ..base.hyperionrequest import HyperionRequest -from ...utility.helpers import get_ua, get_ds, update_device_headers +from ...utility.devices import devices_methods +from ...utility.helpers import get_ua, get_ds __all__ = ("Verify",) @@ -45,7 +46,7 @@ class Verify: headers["Referer"] = referer return headers - def get_headers(self, data: dict = None, params: dict = None): + async def get_headers(self, data: dict = None, params: dict = None): headers = self.BBS_HEADERS.copy() app_version, client_type, ds = get_ds(new_ds=True, data=data, params=params) headers["x-rpc-app_version"] = app_version @@ -53,7 +54,7 @@ class Verify: headers["DS"] = ds headers["x-rpc-challenge_path"] = f"https://{self.HOST}{self.REFERER_URL}" headers["x-rpc-challenge_game"] = self.GAME - update_device_headers(self.account_id, headers) + await devices_methods.update_device_headers(self.account_id, headers) return headers @staticmethod @@ -63,7 +64,7 @@ class Verify: async def create(self, is_high: bool = False): url = self.get_url(self.HOST, self.CREATE_VERIFICATION_URL) params = {"is_high": "true" if is_high else "false"} - headers = self.get_headers(params=params) + headers = await self.get_headers(params=params) response = await self.client.get(url, params=params, headers=headers) return response @@ -71,7 +72,7 @@ class Verify: url = self.get_url(self.HOST, self.VERIFY_VERIFICATION_URL) data = {"geetest_challenge": challenge, "geetest_validate": validate, "geetest_seccode": f"{validate}|jordan"} - headers = self.get_headers(data=data) + headers = await self.get_headers(data=data) response = await self.client.post(url, json=data, headers=headers) return response diff --git a/modules/apihelper/utility/devices.py b/modules/apihelper/utility/devices.py new file mode 100644 index 00000000..91d7d785 --- /dev/null +++ b/modules/apihelper/utility/devices.py @@ -0,0 +1,33 @@ +from typing import Optional, Dict + +from core.services.devices import DevicesService +from modules.apihelper.utility.helpers import get_device_id, hex_digest + + +class DevicesMethods: + def __init__(self): + self.service: Optional[DevicesService] = None + + @staticmethod + def get_default_device_header(account_id: int, headers: Dict = None) -> Dict[str, str]: + headers = headers or {} + headers["x-rpc-device_id"] = get_device_id(str(account_id)) + headers["x-rpc-device_fp"] = hex_digest(headers["x-rpc-device_id"])[:13] + headers["x-rpc-device_name"] = "Xiaomi" + return headers + + async def update_device_headers(self, account_id: int, headers: Dict = None) -> Dict[str, str]: + account_id = account_id or 0 + if not self.service: + return self.get_default_device_header(account_id, headers) + device = await self.service.get(account_id) + if not device: + return self.get_default_device_header(account_id, headers) + headers = headers or {} + headers["x-rpc-device_id"] = device.device_id + headers["x-rpc-device_fp"] = device.device_fp + headers["x-rpc-device_name"] = device.device_name or "Xiaomi" + return headers + + +devices_methods = DevicesMethods() diff --git a/modules/apihelper/utility/helpers.py b/modules/apihelper/utility/helpers.py index 15f44395..c602b035 100644 --- a/modules/apihelper/utility/helpers.py +++ b/modules/apihelper/utility/helpers.py @@ -4,9 +4,9 @@ import random import string import time import uuid -from typing import Any, Mapping, Optional, Dict +from typing import Any, Mapping, Optional -__all__ = ("get_device_id", "hex_digest", "get_ds", "get_recognize_server", "get_ua", "update_device_headers") +__all__ = ("get_device_id", "hex_digest", "get_ds", "get_recognize_server", "get_ua") RECOGNIZE_SERVER = { "1": "cn_gf01", @@ -23,14 +23,6 @@ def get_device_id(name: str = ""): return str(uuid.uuid3(uuid.NAMESPACE_URL, name)) -def update_device_headers(account_id: int, headers: Dict = None) -> Dict[str, str]: - account_id = account_id or 0 - headers = headers or {} - headers["x-rpc-device_id"] = get_device_id(str(account_id)) - headers["x-rpc-device_fp"] = hex_digest(headers["x-rpc-device_id"])[:13] - return headers - - def hex_digest(text): _md5 = hashlib.md5() # nosec B303 _md5.update(text.encode()) diff --git a/plugins/account/devices.py b/plugins/account/devices.py new file mode 100644 index 00000000..87d12a52 --- /dev/null +++ b/plugins/account/devices.py @@ -0,0 +1,187 @@ +from typing import Optional + +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, TelegramObject, Update +from telegram.ext import CallbackContext, ConversationHandler, filters +from telegram.helpers import escape_markdown + +from core.basemodel import RegionEnum +from core.plugin import Plugin, conversation, handler +from core.services.cookies.services import CookiesService +from core.services.devices import DevicesService +from core.services.devices.models import DevicesDataBase as Devices +from core.services.players.services import PlayersService +from modules.apihelper.utility.devices import devices_methods +from utils.log import logger + +__all__ = ("AccountDevicesPlugin",) + + +class AccountDevicesPluginData(TelegramObject): + device_id: str = "" + device_fp: str = "" + device_name: Optional[str] = None + account_id: int = 0 + + def reset(self): + self.device_id = "" + self.device_fp = "" + self.device_name = None + self.account_id = 0 + + +CHECK_SERVER, INPUT_DEVICES, COMMAND_RESULT = range(10100, 10103) + + +class AccountDevicesPlugin(Plugin.Conversation): + """设备绑定""" + + def __init__( + self, + players_service: PlayersService = None, + cookies_service: CookiesService = None, + devices_service: DevicesService = None, + ): + self.cookies_service = cookies_service + self.players_service = players_service + self.devices_service = devices_service + devices_methods.service = devices_service + + @staticmethod + def parse_headers(data: AccountDevicesPluginData, headers_text: str) -> None: + headers = {} + for line in headers_text.splitlines(): + if not line: + continue + try: + k, v = line.split(":", 1) + headers[k.strip()] = v.strip() + except ValueError: + continue + must_keys = {"x-rpc-device_id": 36, "x-rpc-device_fp": 13} + optional_keys = ["x-rpc-device_name"] + for k, v in must_keys.items(): + if (k not in headers) or (not headers.get(k)): + raise ValueError + if len(headers.get(k)) != v: + raise ValueError + for k in optional_keys: + if k not in headers: + continue + if headers.get(k) and len(headers.get(k)) > 64: + raise ValueError + + data.device_id = headers.get("x-rpc-device_id") + data.device_fp = headers.get("x-rpc-device_fp") + data.device_name = headers.get("x-rpc-device_name") + + @conversation.entry_point + @handler.command(command="setdevice", filters=filters.ChatType.PRIVATE, block=False) + @handler.command(command="setdevices", filters=filters.ChatType.PRIVATE, block=False) + async def command_start(self, update: Update, context: CallbackContext) -> int: + user = update.effective_user + message = update.effective_message + logger.info("用户 %s[%s] 绑定设备命令请求", user.full_name, user.id) + account_devices_plugin_data: AccountDevicesPluginData = context.chat_data.get("account_devices_plugin_data") + if account_devices_plugin_data is None: + account_devices_plugin_data = AccountDevicesPluginData() + context.chat_data["account_devices_plugin_data"] = account_devices_plugin_data + else: + account_devices_plugin_data.reset() + + text = f'你好 {user.mention_markdown_v2()} {escape_markdown("!请选择要绑定的服务器!或回复退出取消操作")}' + reply_keyboard = [["米游社"], ["退出"]] + await message.reply_markdown_v2(text, reply_markup=ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True)) + return CHECK_SERVER + + @conversation.state(state=CHECK_SERVER) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def check_server(self, update: Update, context: CallbackContext) -> int: + user = update.effective_user + message = update.effective_message + account_devices_plugin_data: AccountDevicesPluginData = context.chat_data.get("account_devices_plugin_data") + if message.text == "退出": + await message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + if message.text == "米游社": + region = RegionEnum.HYPERION + bbs_name = "米游社" + else: + await message.reply_text("选择错误,请重新选择") + return CHECK_SERVER + player_info = await self.players_service.get(user.id, region=region) + if player_info: + cookies_database = await self.cookies_service.get(user.id, player_info.account_id, region) + if not cookies_database: + await message.reply_text(f"你还没有绑定 {bbs_name} 的Cookies,请先绑定Cookies") + return ConversationHandler.END + account_devices_plugin_data.account_id = player_info.account_id + else: + await message.reply_text(f"你还没有绑定 {bbs_name} 的Cookies,请先绑定Cookies") + return ConversationHandler.END + help_message = ( + "关于如何获取Device\n" + "此操作只能在PC上进行。\n\n" + "PC:\n" + "1、打开通行证并登录\n" + "2、进入通行证按F12打开开发者工具\n" + "3、将开发者工具切换至网络(Network)并点击过滤栏中的 Fetch/XHR 并刷新页面\n" + "4、在请求列表中找到 login_by_cookie\n" + "5、右键并复制请求标头(Request Headers)\n" + "如发现没有此请求大概因为缓存的存在需要你点击禁用缓存(Disable Cache)再次刷新页面" + ) + await message.reply_html(help_message, disable_web_page_preview=True) + await message.reply_text(f"请输入{bbs_name}的请求标头!或回复退出取消操作", reply_markup=ReplyKeyboardRemove()) + return INPUT_DEVICES + + @conversation.state(state=INPUT_DEVICES) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def input_cookies(self, update: Update, context: CallbackContext) -> int: + message = update.effective_message + user = update.effective_user + account_devices_plugin_data: AccountDevicesPluginData = context.chat_data.get("account_devices_plugin_data") + if message.text == "退出": + await message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + try: + self.parse_headers(account_devices_plugin_data, message.text) + except ValueError as exc: + logger.info("用户 %s[%s] Devices解析出现错误\ntext:%s", user.full_name, user.id, message.text) + logger.debug("解析Devices出现错误", exc_info=exc) + await message.reply_text("解析Devices出现错误,请检查是否正确", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + reply_keyboard = [["确认", "退出"]] + await message.reply_markdown_v2( + "请确认修改!", reply_markup=ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True) + ) + return COMMAND_RESULT + + @conversation.state(state=COMMAND_RESULT) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def command_result(self, update: Update, context: CallbackContext) -> int: + user = update.effective_user + message = update.effective_message + account_devices_plugin_data: AccountDevicesPluginData = context.chat_data.get("account_devices_plugin_data") + if message.text == "退出": + await message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + if message.text == "确认": + device = await self.devices_service.get(account_devices_plugin_data.account_id) + if device: + device.device_id = account_devices_plugin_data.device_id + device.device_fp = account_devices_plugin_data.device_fp + device.device_name = account_devices_plugin_data.device_name + await self.devices_service.update(device) + logger.success("用户 %s[%s] 更新Devices", user.full_name, user.id) + else: + device = Devices( + account_id=account_devices_plugin_data.account_id, + device_id=account_devices_plugin_data.device_id, + device_fp=account_devices_plugin_data.device_fp, + device_name=account_devices_plugin_data.device_name, + ) + await self.devices_service.add(device) + logger.info("用户 %s[%s] 绑定Devices成功", user.full_name, user.id) + await message.reply_text("保存成功", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await message.reply_text("回复错误,请重新输入") + return COMMAND_RESULT diff --git a/utils/genshin.py b/utils/genshin.py index d35fd76a..c897261e 100644 --- a/utils/genshin.py +++ b/utils/genshin.py @@ -4,7 +4,8 @@ from genshin import Client from genshin.client.routes import InternationalRoute # noqa F401 from genshin.utility import recognize_genshin_server -from modules.apihelper.utility.helpers import hex_digest, get_ds, update_device_headers +from modules.apihelper.utility.devices import devices_methods +from modules.apihelper.utility.helpers import hex_digest, get_ds AUTHKEY_API = "https://api-takumi.mihoyo.com/binding/api/genAuthKey" HK4E_LOGIN_URL = InternationalRoute( @@ -37,7 +38,7 @@ async def get_authkey_by_stoken(client: Client) -> Optional[str]: } device_id = hex_digest(str(client.uid)) device = f"Paimon Build {device_id[:5]}" - update_device_headers(client.hoyolab_id, headers) + await devices_methods.update_device_headers(client.hoyolab_id, headers) headers["x-rpc-device_name"] = device headers["x-rpc-device_model"] = device app_version, client_type, ds_sign = get_ds() diff --git a/utils/patch/genshin.py b/utils/patch/genshin.py index 85fd3b4d..8f691710 100644 --- a/utils/patch/genshin.py +++ b/utils/patch/genshin.py @@ -9,8 +9,8 @@ from genshin import constants, types, utility from genshin.client import routes from genshin.utility import generate_dynamic_secret, ds -from modules.apihelper.utility.helpers import get_ds, get_ua, get_device_id, hex_digest, update_device_headers -from utils.log import logger +from modules.apihelper.utility.devices import devices_methods +from modules.apihelper.utility.helpers import get_ds, get_ua, get_device_id, hex_digest from utils.patch.methods import patch, patchable DEVICE_ID = get_device_id() @@ -156,7 +156,7 @@ class BaseClient: "x-rpc-client_type": client_type, "ds": ds_sign, } - update_device_headers(self.hoyolab_id, headers) + await devices_methods.update_device_headers(self.hoyolab_id, headers) else: raise TypeError(f"{region!r} is not a valid region.") @@ -190,8 +190,7 @@ class BaseClient: headers = dict(headers or {}) headers.setdefault("User-Agent", self.USER_AGENT) - update_device_headers(self.hoyolab_id, headers) - logger.debug("Account ID: %s Header: %s" % (self.hoyolab_id, headers)) + await devices_methods.update_device_headers(self.hoyolab_id, headers) if method is None: method = "POST" if data else "GET" @@ -264,7 +263,7 @@ class BaseClient: "ds": ds_sign, } headers.update(add_headers) - update_device_headers(self.hoyolab_id, headers) + await devices_methods.update_device_headers(self.hoyolab_id, headers) elif self.region == types.Region.OVERSEAS: headers.update(ds.get_ds_headers(data=data, params=params, region=region, lang=lang or self.lang)) headers["Referer"] = str(routes.BBS_REFERER_URL.get_url(self.region)) @@ -349,7 +348,7 @@ class DailyRewardClient: headers["x-rpc-challenge"] = challenge headers["x-rpc-validate"] = validate headers["x-rpc-seccode"] = f"{validate}|jordan" - update_device_headers(self.hoyolab_id, headers) + await devices_methods.update_device_headers(self.hoyolab_id, headers) else: raise TypeError(f"{self.region!r} is not a valid region.")