mirror of
https://github.com/PaiGramTeam/PaiGram.git
synced 2024-12-26 19:35:37 +00:00
438 lines
18 KiB
Python
438 lines
18 KiB
Python
import asyncio
|
|
import random
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, time, timedelta
|
|
from typing import Optional, Any
|
|
from typing import TYPE_CHECKING, Union
|
|
|
|
from pydantic import ValidationError
|
|
from simnet import GenshinClient, Region
|
|
from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, NetworkError, CookieException, NeedChallenge
|
|
from simnet.models.genshin.calculator import CalculatorCharacterDetails
|
|
from simnet.models.genshin.chronicle.characters import Character
|
|
from simnet.utils.player import recognize_game_biz
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm.exc import StaleDataError
|
|
from sqlmodel import BigInteger, Column, DateTime, Field, Index, Integer, SQLModel, String, delete, func, select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
from telegram.ext import ContextTypes
|
|
|
|
from core.basemodel import RegionEnum
|
|
from core.dependence.database import Database
|
|
from core.dependence.redisdb import RedisDB
|
|
from core.error import ServiceNotFoundError
|
|
from core.plugin import Plugin
|
|
from core.services.cookies.services import CookiesService, PublicCookiesService
|
|
from core.services.devices import DevicesService
|
|
from core.services.players.services import PlayersService
|
|
from core.services.users.services import UserService
|
|
from gram_core.services.cookies.models import CookiesStatusEnum
|
|
from utils.log import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy import Table
|
|
|
|
__all__ = (
|
|
"GenshinHelper",
|
|
"PlayerNotFoundError",
|
|
"CookiesNotFoundError",
|
|
"CookiesUpdateRequestError",
|
|
"CharacterDetails",
|
|
)
|
|
|
|
|
|
class CharacterDetailsSQLModel(SQLModel, table=True):
|
|
__tablename__ = "character_details"
|
|
__table_args__ = (
|
|
Index("index_player_character", "player_id", "character_id", unique=True),
|
|
dict(mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci"),
|
|
)
|
|
id: Optional[int] = Field(default=None, sa_column=Column(Integer, primary_key=True, autoincrement=True))
|
|
player_id: int = Field(sa_column=Column(BigInteger()))
|
|
character_id: int = Field(sa_column=Column(BigInteger()))
|
|
data: Optional[str] = Field(sa_column=Column(String(length=4096)))
|
|
time_updated: Optional[datetime] = Field(sa_column=Column(DateTime, onupdate=func.now())) # pylint: disable=E1102
|
|
|
|
|
|
class CharacterDetails(Plugin):
|
|
def __init__(
|
|
self,
|
|
database: Database,
|
|
redis: RedisDB,
|
|
) -> None:
|
|
self.database = database
|
|
self.redis = redis.client
|
|
self.expire = 60 * 60
|
|
|
|
async def initialize(self) -> None:
|
|
def fetch_and_update_objects(connection):
|
|
if not self.database.engine.dialect.has_table(connection, table_name="character_details"):
|
|
logger.info("正在创建角色详细信息表")
|
|
table: "Table" = SQLModel.metadata.tables["character_details"]
|
|
table.create(connection)
|
|
logger.success("创建角色详细信息表成功")
|
|
|
|
async with self.database.engine.begin() as conn:
|
|
await conn.run_sync(fetch_and_update_objects)
|
|
self.application.job_queue.run_daily(self.del_old_data_job, time(hour=12, minute=0))
|
|
|
|
async def del_old_data_job(self, _: ContextTypes.DEFAULT_TYPE):
|
|
await self.del_old_data(timedelta(days=7))
|
|
|
|
async def del_old_data(self, expiration_time: timedelta):
|
|
expire_time = datetime.now() - expiration_time
|
|
statement = delete(CharacterDetailsSQLModel).where(CharacterDetailsSQLModel.time_updated <= expire_time)
|
|
async with AsyncSession(self.database.engine) as session:
|
|
await session.execute(statement)
|
|
|
|
@staticmethod
|
|
def get_qname(uid: int, character: int):
|
|
return f"plugin:character_details:{uid}:{character}"
|
|
|
|
async def get_character_details_for_redis(
|
|
self,
|
|
uid: int,
|
|
character_id: int,
|
|
) -> Optional["CalculatorCharacterDetails"]:
|
|
name = self.get_qname(uid, character_id)
|
|
data = await self.redis.get(name)
|
|
if data is None:
|
|
return None
|
|
json_data = str(data, encoding="utf-8")
|
|
return CalculatorCharacterDetails.parse_raw(json_data)
|
|
|
|
async def set_character_details(self, player_id: int, character_id: int, data: str):
|
|
randint = random.randint(1, 30) # nosec
|
|
await self.redis.set(
|
|
self.get_qname(player_id, character_id), data, ex=self.expire + randint * 60
|
|
) # 使用随机数防止缓存雪崩
|
|
async with AsyncSession(self.database.engine) as session:
|
|
statement = (
|
|
select(CharacterDetailsSQLModel)
|
|
.where(CharacterDetailsSQLModel.player_id == player_id)
|
|
.where(CharacterDetailsSQLModel.character_id == character_id)
|
|
)
|
|
results = await session.exec(statement)
|
|
sql_data = results.first()
|
|
if sql_data is None:
|
|
sql_data = CharacterDetailsSQLModel(
|
|
player_id=player_id, character_id=character_id, data=data, time_updated=datetime.now()
|
|
)
|
|
async with AsyncSession(self.database.engine) as session:
|
|
session.add(sql_data)
|
|
await session.commit()
|
|
else:
|
|
sql_data.data = data
|
|
sql_data.time_updated = datetime.now()
|
|
async with AsyncSession(self.database.engine) as session:
|
|
session.add(sql_data)
|
|
await session.commit()
|
|
|
|
async def set_character_details_task(self, player_id: int, character_id: int, data: str):
|
|
try:
|
|
await self.set_character_details(player_id, character_id, data)
|
|
except SQLAlchemyError as exc:
|
|
logger.error("写入到数据库失败 code[%s]", exc.code)
|
|
logger.debug("写入到数据库失败", exc_info=exc)
|
|
except Exception as exc:
|
|
logger.error("set_character_details 执行失败", exc_info=exc)
|
|
|
|
async def get_character_details_for_mysql(
|
|
self,
|
|
uid: int,
|
|
character_id: int,
|
|
) -> Optional["CalculatorCharacterDetails"]:
|
|
async with AsyncSession(self.database.engine) as session:
|
|
statement = (
|
|
select(CharacterDetailsSQLModel)
|
|
.where(CharacterDetailsSQLModel.player_id == uid)
|
|
.where(CharacterDetailsSQLModel.character_id == character_id)
|
|
)
|
|
results = await session.exec(statement)
|
|
data = results.first()
|
|
if data is not None:
|
|
try:
|
|
return CalculatorCharacterDetails.parse_raw(data.data)
|
|
except ValidationError as exc:
|
|
logger.error("解析数据出现异常 ValidationError", exc_info=exc)
|
|
await session.delete(data)
|
|
await session.commit()
|
|
except ValueError as exc:
|
|
logger.error("解析数据出现异常 ValueError", exc_info=exc)
|
|
await session.delete(data)
|
|
await session.commit()
|
|
return None
|
|
|
|
async def get_character_details(
|
|
self, client: "GenshinClient", character: "Union[int,Character]"
|
|
) -> Optional["CalculatorCharacterDetails"]:
|
|
"""缓存 character_details 并定时对其进行数据存储 当遇到 Too Many Requests 可以获取以前的数据"""
|
|
uid = client.player_id
|
|
if isinstance(character, Character):
|
|
character_id = character.id
|
|
else:
|
|
character_id = character
|
|
if uid is not None:
|
|
detail = await self.get_character_details_for_redis(uid, character_id)
|
|
if detail is not None:
|
|
return detail
|
|
try:
|
|
detail = await client.get_character_details(character_id)
|
|
except SimnetBadRequest as exc:
|
|
if "Too Many Requests" in exc.message:
|
|
return await self.get_character_details_for_mysql(uid, character_id)
|
|
raise exc
|
|
asyncio.create_task(self.set_character_details(uid, character_id, detail.json(by_alias=True)))
|
|
return detail
|
|
try:
|
|
return await client.get_character_details(character_id)
|
|
except SimnetBadRequest as exc:
|
|
if "Too Many Requests" in exc.message:
|
|
logger.warning("Too Many Requests")
|
|
else:
|
|
raise exc
|
|
return None
|
|
|
|
|
|
class PlayerNotFoundError(Exception):
|
|
def __init__(self, user_id):
|
|
super().__init__(f"User not found, user_id: {user_id}")
|
|
|
|
|
|
class CookiesNotFoundError(Exception):
|
|
def __init__(self, user_id: int, region: Optional[RegionEnum] = None):
|
|
self.user_id = user_id
|
|
self.region = region
|
|
super().__init__(f"{user_id} cookies not found")
|
|
|
|
|
|
class CookiesUpdateRequestError(Exception):
|
|
def __init__(self, new_cookies: dict[str, Any]):
|
|
self.new_cookies = new_cookies
|
|
super().__init__("cookies need update")
|
|
|
|
|
|
class GenshinHelper(Plugin):
|
|
def __init__(
|
|
self,
|
|
cookies: CookiesService,
|
|
public_cookies: PublicCookiesService,
|
|
user: UserService,
|
|
player: PlayersService,
|
|
devices: DevicesService,
|
|
) -> None:
|
|
self.cookies_service = cookies
|
|
self.public_cookies_service = public_cookies
|
|
self.user_service = user
|
|
self.players_service = player
|
|
self.devices_service = devices
|
|
if None in (temp := [self.user_service, self.cookies_service, self.players_service]):
|
|
raise ServiceNotFoundError(*filter(lambda x: x is None, temp))
|
|
|
|
@asynccontextmanager
|
|
async def genshin( # skipcq: PY-R1000 #
|
|
self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0
|
|
) -> GenshinClient:
|
|
player = await self.players_service.get_player(user_id, region, player_id, offset)
|
|
if player is None:
|
|
raise PlayerNotFoundError(user_id)
|
|
|
|
if player.account_id is None:
|
|
raise CookiesNotFoundError(user_id, player.region)
|
|
cookie_model = await self.cookies_service.get(player.user_id, player.account_id, player.region)
|
|
if cookie_model is None:
|
|
raise CookiesNotFoundError(user_id, player.region)
|
|
cookies = cookie_model.data
|
|
|
|
if player.region == RegionEnum.HYPERION: # 国服
|
|
region = Region.CHINESE
|
|
elif player.region == RegionEnum.HOYOLAB: # 国际服
|
|
region = Region.OVERSEAS
|
|
else:
|
|
raise TypeError("Region is not None")
|
|
|
|
device_id: Optional[str] = None
|
|
device_fp: Optional[str] = None
|
|
devices = await self.devices_service.get(player.account_id)
|
|
if devices:
|
|
device_id = devices.device_id
|
|
device_fp = devices.device_fp
|
|
|
|
async def _update_cookie_model():
|
|
try:
|
|
await self.cookies_service.update(cookie_model)
|
|
except StaleDataError as __exc:
|
|
if "UPDATE" in str(__exc):
|
|
logger.warning("用户 user_id[%s] 刷新 Cookies 失败,数据不存在", cookie_model.user_id)
|
|
else:
|
|
logger.error("用户 user_id[%s] 更新 Cookies 时出现错误", cookie_model.user_id, exc_info=__exc)
|
|
except Exception as __exc:
|
|
logger.error("用户 user_id[%s] 更新 Cookies 失败", cookie_model.user_id, exc_info=__exc)
|
|
|
|
async with GenshinClient(
|
|
cookies,
|
|
region=region,
|
|
account_id=player.account_id,
|
|
player_id=player.player_id,
|
|
lang="zh-cn",
|
|
device_id=device_id,
|
|
device_fp=device_fp,
|
|
) as client:
|
|
try:
|
|
yield client
|
|
except CookiesUpdateRequestError as exc:
|
|
new_cookies = cookie_model.data.copy()
|
|
new_cookies.update(exc.new_cookies)
|
|
cookie_model.data = new_cookies
|
|
cookie_model.status = CookiesStatusEnum.STATUS_SUCCESS
|
|
await _update_cookie_model()
|
|
raise CookieException(message="The cookie has been refreshed.") from exc
|
|
except InvalidCookies as exc:
|
|
if exc.retcode == 10103:
|
|
raise exc
|
|
refresh = False
|
|
cookie_model.status = CookiesStatusEnum.INVALID_COOKIES
|
|
stoken = client.cookies.get("stoken")
|
|
if stoken is not None:
|
|
try:
|
|
new_cookies = cookie_model.data.copy()
|
|
new_cookies["cookie_token"] = await client.get_cookie_token_by_stoken()
|
|
logger.success("用户 %s 刷新 cookie_token 成功", user_id)
|
|
new_cookies["ltoken"] = await client.get_ltoken_by_stoken()
|
|
logger.success("用户 %s 刷新 ltoken 成功", user_id)
|
|
cookie_model.data = new_cookies
|
|
cookie_model.status = CookiesStatusEnum.STATUS_SUCCESS
|
|
except ValueError as _exc:
|
|
logger.info("用户 user_id[%s] Cookies 不完整 [%s]", cookie_model.user_id, str(_exc))
|
|
except InvalidCookies:
|
|
logger.info("用户 user_id[%s] Cookies 已经过期", cookie_model.user_id)
|
|
except SimnetBadRequest as _exc:
|
|
logger.warning(
|
|
"用户 %s 刷新 token 失败 [%s]%s", user_id, _exc.ret_code, _exc.original or _exc.message
|
|
)
|
|
cookie_model.status = CookiesStatusEnum.STATUS_SUCCESS
|
|
except NetworkError:
|
|
logger.warning("用户 %s 刷新 Cookies 失败 网络错误", user_id)
|
|
cookie_model.status = CookiesStatusEnum.STATUS_SUCCESS
|
|
except Exception as _exc:
|
|
logger.error("用户 %s 刷新 Cookies 失败", user_id, exc_info=_exc)
|
|
else:
|
|
refresh = True
|
|
await _update_cookie_model()
|
|
if refresh:
|
|
raise CookieException(message="The cookie has been refreshed.") from exc
|
|
raise exc
|
|
except NeedChallenge as exc:
|
|
if devices is not None:
|
|
devices.is_valid = False
|
|
await self.devices_service.update(devices)
|
|
raise exc
|
|
|
|
async def get_genshin_client(
|
|
self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0
|
|
) -> GenshinClient:
|
|
player = await self.players_service.get_player(user_id, region, player_id, offset)
|
|
if player is None:
|
|
raise PlayerNotFoundError(user_id)
|
|
|
|
if player.account_id is None:
|
|
raise CookiesNotFoundError(user_id, player.region)
|
|
cookie_model = await self.cookies_service.get(player.user_id, player.account_id, player.region)
|
|
if cookie_model is None:
|
|
raise CookiesNotFoundError(user_id, player.region)
|
|
cookies = cookie_model.data
|
|
|
|
if player.region == RegionEnum.HYPERION:
|
|
region = Region.CHINESE
|
|
elif player.region == RegionEnum.HOYOLAB:
|
|
region = Region.OVERSEAS
|
|
else:
|
|
raise TypeError("Region is not None")
|
|
|
|
device_id: Optional[str] = None
|
|
device_fp: Optional[str] = None
|
|
devices = await self.devices_service.get(player.account_id)
|
|
if devices:
|
|
device_id = devices.device_id
|
|
device_fp = devices.device_fp
|
|
|
|
return GenshinClient(
|
|
cookies,
|
|
region=region,
|
|
account_id=player.account_id,
|
|
player_id=player.player_id,
|
|
lang="zh-cn",
|
|
device_id=device_id,
|
|
device_fp=device_fp,
|
|
)
|
|
|
|
@asynccontextmanager
|
|
async def public_genshin(
|
|
self, user_id: int, region: Optional[RegionEnum] = None, uid: Optional[int] = None
|
|
) -> GenshinClient:
|
|
if not (region or uid):
|
|
player = await self.players_service.get_player(user_id, region)
|
|
if player:
|
|
region = player.region
|
|
uid = player.player_id
|
|
|
|
cookies = await self.public_cookies_service.get_cookies(user_id, region)
|
|
|
|
if region == RegionEnum.HYPERION:
|
|
region = Region.CHINESE
|
|
elif region == RegionEnum.HOYOLAB:
|
|
region = Region.OVERSEAS
|
|
else:
|
|
raise TypeError("Region is not `RegionEnum.NULL`")
|
|
|
|
device_id: Optional[str] = None
|
|
device_fp: Optional[str] = None
|
|
devices = await self.devices_service.get(cookies.account_id)
|
|
if devices:
|
|
device_id = devices.device_id
|
|
device_fp = devices.device_fp
|
|
|
|
async with GenshinClient(
|
|
cookies.data,
|
|
region=region,
|
|
player_id=uid,
|
|
lang="zh-cn",
|
|
device_id=device_id,
|
|
device_fp=device_fp,
|
|
) as client:
|
|
try:
|
|
yield client
|
|
except NeedChallenge as exc:
|
|
await self.public_cookies_service.undo(user_id)
|
|
await self.public_cookies_service.set_device_valid(client.account_id, False)
|
|
raise exc
|
|
|
|
@asynccontextmanager
|
|
async def genshin_or_public(
|
|
self,
|
|
user_id: int,
|
|
region: Optional[RegionEnum] = None,
|
|
uid: Optional[int] = None,
|
|
offset: int = 0,
|
|
) -> GenshinClient:
|
|
try:
|
|
async with self.genshin(user_id, region, uid, offset) as client:
|
|
client.public = False
|
|
if uid and recognize_game_biz(uid, client.game) != recognize_game_biz(client.player_id, client.game):
|
|
# 如果 uid 和 player_id 服务器不一致,说明是跨服的,需要使用公共的 cookies
|
|
raise CookiesNotFoundError(user_id)
|
|
yield client
|
|
except (CookiesNotFoundError, PlayerNotFoundError):
|
|
if uid:
|
|
if uid < 10:
|
|
raise PlayerNotFoundError(user_id)
|
|
region = RegionEnum.HYPERION if uid < 600000000 else RegionEnum.HOYOLAB
|
|
if uid is None and region is None:
|
|
raise PlayerNotFoundError(user_id)
|
|
async with self.public_genshin(user_id, region, uid) as client:
|
|
try:
|
|
client.public = True
|
|
yield client
|
|
except NeedChallenge as exc:
|
|
raise CookiesNotFoundError(user_id) from exc
|