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.")