👷 Update to MibooGram

This commit is contained in:
xtaodada 2024-07-04 19:09:48 +08:00
parent 1cd7cb83cd
commit fc28f68bd0
Signed by: xtaodada
GPG Key ID: 4CBB3F4FA8C85659
95 changed files with 1627 additions and 10184 deletions

View File

@ -1,566 +0,0 @@
"""用于下载和管理角色、武器、材料等的图标"""
from __future__ import annotations
import asyncio
import re
from abc import ABC, abstractmethod
from functools import cached_property, lru_cache, partial
from multiprocessing import RLock as Lock
from pathlib import Path
from ssl import SSLZeroReturnError
from typing import AsyncIterator, Awaitable, Callable, ClassVar, Dict, Optional, TYPE_CHECKING, TypeVar, Union
from aiofiles import open as async_open
from aiofiles.os import remove as async_remove
from enkanetwork import Assets as EnkaAssets
from enkanetwork.model.assets import CharacterAsset as EnkaCharacterAsset
from httpx import AsyncClient, HTTPError, HTTPStatusError, TransportError, URL
from typing_extensions import Self
from core.base_service import BaseService
from core.config import config
from metadata.genshin import AVATAR_DATA, HONEY_DATA, MATERIAL_DATA, NAMECARD_DATA, WEAPON_DATA
from metadata.scripts.honey import update_honey_metadata
from metadata.scripts.metadatas import update_metadata_from_ambr, update_metadata_from_github
from metadata.shortname import roleToId, weaponToId
from modules.wiki.base import HONEY_HOST
from utils.const import AMBR_HOST, ENKA_HOST, PROJECT_ROOT
from utils.log import logger
from utils.typedefs import StrOrInt, StrOrURL
if TYPE_CHECKING:
from httpx import Response
from multiprocessing.synchronize import RLock
__all__ = ("AssetsServiceType", "AssetsService", "AssetsServiceError", "AssetsCouldNotFound", "DEFAULT_EnkaAssets")
ICON_TYPE = Union[Callable[[bool], Awaitable[Optional[Path]]], Callable[..., Awaitable[Optional[Path]]]]
NAME_MAP_TYPE = Dict[str, StrOrURL]
ASSETS_PATH = PROJECT_ROOT.joinpath("resources/assets")
ASSETS_PATH.mkdir(exist_ok=True, parents=True)
DATA_MAP = {"avatar": AVATAR_DATA, "weapon": WEAPON_DATA, "material": MATERIAL_DATA}
DEFAULT_EnkaAssets = EnkaAssets(lang="chs")
class AssetsServiceError(Exception):
pass
class AssetsCouldNotFound(AssetsServiceError):
def __init__(self, message: str, target: str):
self.message = message
self.target = target
super().__init__(f"{message}: target={target}")
class _AssetsService(ABC):
_lock: ClassVar["RLock"] = Lock()
_dir: ClassVar[Path]
icon_types: ClassVar[list[str]]
_client: Optional[AsyncClient] = None
_links: dict[str, str] = {}
id: int
type: str
icon: ICON_TYPE
"""图标"""
@abstractmethod
@cached_property
def game_name(self) -> str:
"""游戏数据中的名称"""
@cached_property
def honey_id(self) -> str:
"""当前资源在 Honey Impact 所对应的 ID"""
return HONEY_DATA[self.type].get(str(self.id), [""])[0]
@property
def path(self) -> Path:
"""当前资源的文件夹"""
result = self._dir.joinpath(str(self.id)).resolve()
result.mkdir(exist_ok=True, parents=True)
return result
@property
def client(self) -> AsyncClient:
with self._lock:
if self._client is None or self._client.is_closed:
self._client = AsyncClient()
return self._client
def __init__(self, client: Optional[AsyncClient] = None) -> None:
self._client = client
def __call__(self, target: int) -> Self:
"""用于生成与 target 对应的 assets"""
result = self.__class__(self.client)
result.id = target
return result
def __init_subclass__(cls, **kwargs) -> None:
"""初始化一些类变量"""
from itertools import chain
cls.icon_types = [ # 支持的图标类型
k
for k, v in chain(cls.__annotations__.items(), *map(lambda x: x.__annotations__.items(), cls.__bases__))
if v in [ICON_TYPE, "ICON_TYPE"]
]
cls.type = cls.__name__.lstrip("_").split("Assets")[0].lower() # 当前 assert 的类型
cls._dir = ASSETS_PATH.joinpath(cls.type) # 图标保存的文件夹
cls._dir.mkdir(exist_ok=True, parents=True)
async def _request(self, url: str, interval: float = 0.2) -> "Response":
error = None
for _ in range(5):
try:
response = await self.client.get(url, follow_redirects=False)
if response.headers.get("content-length", None) == "2358":
continue
return response
except (TransportError, SSLZeroReturnError) as e:
error = e
await asyncio.sleep(interval)
continue
if error is not None:
raise error
async def _download(self, url: StrOrURL, path: Path, retry: int = 5) -> Path | None:
"""从 url 下载图标至 path"""
logger.debug("正在从 %s 下载图标至 %s", url, path)
headers = None
if config.enka_network_api_agent is not None and URL(url).host == "enka.network":
headers = {"user-agent": config.enka_network_api_agent}
for time in range(retry):
try:
response = await self.client.get(url, follow_redirects=False, headers=headers)
except Exception as error: # pylint: disable=W0703
if not isinstance(error, (HTTPError, SSLZeroReturnError)):
logger.error(error) # 打印未知错误
if time != retry - 1: # 未达到重试次数
await asyncio.sleep(1)
else:
raise error
continue
if response.status_code != 200: # 判定页面是否正常
return None
async with async_open(path, "wb") as file:
await file.write(response.content) # 保存图标
return path.resolve()
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]: # pylint: disable=W0613,R0201
"""从 ambr.top 上获取目标链接"""
yield None
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]: # pylint: disable=W0613,R0201
"""从 enke.network 上获取目标链接"""
yield None
async def _get_from_honey(self, item: str) -> AsyncIterator[str | None]:
"""从 honey 上获取目标链接"""
if (honey_name := self.honey_name_map.get(item, None)) is not None:
yield HONEY_HOST.join(f"img/{honey_name}.png")
yield HONEY_HOST.join(f"img/{honey_name}.webp")
async def _download_url_generator(self, item: str) -> AsyncIterator[str]:
# 获取当前 `AssetsService` 的所有爬虫
for func in map(lambda x: getattr(self, x), sorted(filter(lambda x: x.startswith("_get_from_"), dir(self)))):
async for url in func(item):
if url is not None:
try:
if (response := await self._request(url := str(url))) is None:
continue
response.raise_for_status()
yield url
except HTTPStatusError:
continue
async def _get_download_url(self, item: str) -> str | None:
"""获取图标的下载链接"""
async for url in self._download_url_generator(item):
if url is not None:
return url
async def _get_img(self, overwrite: bool = False, *, item: str) -> Path | None:
"""获取图标"""
path = next(filter(lambda x: x.stem == item, self.path.iterdir()), None)
if not overwrite and path: # 如果需要下载的图标存在且不覆盖( overwrite )
return path.resolve()
if path is not None and path.exists():
if overwrite: # 如果覆盖
await async_remove(path) # 删除已存在的图标
else:
return path
# 依次从使用当前 assets class 中的爬虫下载图标,顺序为爬虫名的字母顺序
async for url in self._download_url_generator(item):
if url is not None:
path = self.path.joinpath(f"{item}{Path(url).suffix}")
if (result := await self._download(url, path)) is not None:
return result
@lru_cache
async def get_link(self, item: str) -> str | None:
"""获取相应图标链接"""
return await self._get_download_url(item)
def __getattr__(self, item: str):
"""魔法"""
if item in self.icon_types:
return partial(self._get_img, item=item)
object.__getattribute__(self, item)
return None
@abstractmethod
@cached_property
def game_name_map(self) -> dict[str, str]:
"""游戏中的图标名"""
@abstractmethod
@cached_property
def honey_name_map(self) -> dict[str, str]:
"""来自honey的图标名"""
class _AvatarAssets(_AssetsService):
enka: EnkaCharacterAsset | None
side: ICON_TYPE
"""侧视图图标"""
card: ICON_TYPE
"""卡片图标"""
gacha: ICON_TYPE
"""抽卡立绘"""
gacha_card: ICON_TYPE
"""抽卡卡片"""
AVATAR_DEFAULT: int = 10000005
"""默认角色ID"""
@cached_property
def game_name(self) -> str:
icon = "UI_AvatarIcon_"
if (avatar := AVATAR_DATA.get(str(self.id), None)) is not None:
icon = avatar["icon"]
else:
for aid, avatar in AVATAR_DATA.items():
if aid.startswith(str(self.id)):
icon = avatar["icon"]
return re.findall(r"UI_AvatarIcon_(.*)", icon)[0]
@cached_property
def honey_id(self) -> str:
return HONEY_DATA["avatar"].get(str(self.id), "")[0]
@cached_property
def enka(self) -> Optional[EnkaCharacterAsset]:
api = getattr(self, "_enka_api", None)
cid = getattr(self, "id", None)
return None if api is None or cid is None else api.character(cid)
def __init__(self, client: Optional[AsyncClient] = None, enka: Optional[EnkaAssets] = None):
super().__init__(client)
self._enka_api = enka or DEFAULT_EnkaAssets
def __call__(self, target: StrOrInt) -> "_AvatarAssets":
if target == 0:
target = self.AVATAR_DEFAULT
temp = target
result = _AvatarAssets(self.client)
if isinstance(target, str):
try:
target = int(target)
except ValueError:
target = roleToId(target)
if isinstance(target, str) or target is None:
raise AssetsCouldNotFound("找不到对应的角色", temp)
result.id = target
result._enka_api = self._enka_api
return result
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
if item in {"icon", "side", "gacha"}:
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map[item]}.png"))
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
if (item_id := self.game_name_map.get(item)) is not None:
yield str(ENKA_HOST.join(f"ui/{item_id}.png"))
@cached_property
def honey_name_map(self) -> dict[str, str]:
return {
"icon": f"{self.honey_id}_icon",
"side": f"{self.honey_id}_side_icon",
"gacha": f"{self.honey_id}_gacha_splash",
"gacha_card": f"{self.honey_id}_gacha_card",
}
@cached_property
def game_name_map(self) -> dict[str, str]:
return {
"icon": f"UI_AvatarIcon_{self.game_name}",
"card": f"UI_AvatarIcon_{self.game_name}_Card",
"side": f"UI_AvatarIcon_Side_{self.game_name}",
"gacha": f"UI_Gacha_AvatarImg_{self.game_name}",
}
class _WeaponAssets(_AssetsService):
awaken: ICON_TYPE
"""突破后图标"""
gacha: ICON_TYPE
"""抽卡立绘"""
@cached_property
def game_name(self) -> str:
return re.findall(r"UI_EquipIcon_(.*)", WEAPON_DATA[str(self.id)]["icon"])[0]
@cached_property
def game_name_map(self) -> dict[str, str]:
return {
"icon": f"UI_EquipIcon_{self.game_name}",
"awaken": f"UI_EquipIcon_{self.game_name}_Awaken",
"gacha": f"UI_Gacha_EquipIcon_{self.game_name}",
}
@cached_property
def honey_id(self) -> str:
return f"i_n{self.id}"
def __call__(self, target: StrOrInt) -> Self:
temp = target
result = _WeaponAssets(self.client)
if isinstance(target, str):
target = int(target) if target.isnumeric() else weaponToId(target)
if isinstance(target, str) or target is None:
raise AssetsCouldNotFound("找不到对应的武器", temp)
result.id = target
return result
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
if item == "icon":
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map.get(item)}.png"))
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
if item in self.game_name_map:
yield str(ENKA_HOST.join(f"ui/{self.game_name_map.get(item)}.png"))
@cached_property
def honey_name_map(self) -> dict[str, str]:
return {
"icon": f"{self.honey_id}",
"awaken": f"{self.honey_id}_awaken_icon",
"gacha": f"{self.honey_id}_gacha_icon",
}
class _MaterialAssets(_AssetsService):
@cached_property
def game_name(self) -> str:
return str(self.id)
@cached_property
def game_name_map(self) -> dict[str, str]:
return {"icon": f"UI_ItemIcon_{self.game_name}"}
@cached_property
def honey_name_map(self) -> dict[str, str]:
return {"icon": self.honey_id}
def __call__(self, target: StrOrInt) -> Self:
temp = target
result = _MaterialAssets(self.client)
if isinstance(target, str):
if target.isnumeric():
target = int(target)
else:
target = {v["name"]: int(k) for k, v in MATERIAL_DATA.items()}.get(target)
if isinstance(target, str) or target is None:
raise AssetsCouldNotFound("找不到对应的素材", temp)
result.id = target
return result
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
if item == "icon":
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map.get(item)}.png"))
async def _get_from_honey(self, item: str) -> AsyncIterator[str | None]:
yield HONEY_HOST.join(f"/img/{self.honey_name_map.get(item)}.png")
yield HONEY_HOST.join(f"/img/{self.honey_name_map.get(item)}.webp")
class _ArtifactAssets(_AssetsService):
flower: ICON_TYPE
"""生之花"""
plume: ICON_TYPE
"""死之羽"""
sands: ICON_TYPE
"""时之沙"""
goblet: ICON_TYPE
"""空之杯"""
circlet: ICON_TYPE
"""理之冠"""
@cached_property
def honey_id(self) -> str:
return HONEY_DATA["artifact"][str(self.id)][0]
@cached_property
def game_name(self) -> str:
return f"UI_RelicIcon_{self.id}"
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
if item in self.game_name_map:
yield str(ENKA_HOST.join(f"ui/{self.game_name_map.get(item)}.png"))
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
if item in self.game_name_map:
yield str(AMBR_HOST.join(f"assets/UI/reliquary/{self.game_name_map[item]}.png"))
@cached_property
def game_name_map(self) -> dict[str, str]:
return {
"icon": f"UI_RelicIcon_{self.id}_4",
"flower": f"UI_RelicIcon_{self.id}_4",
"plume": f"UI_RelicIcon_{self.id}_2",
"sands": f"UI_RelicIcon_{self.id}_5",
"goblet": f"UI_RelicIcon_{self.id}_1",
"circlet": f"UI_RelicIcon_{self.id}_3",
}
@cached_property
def honey_name_map(self) -> dict[str, str]:
first_id = int(re.findall(r"\d+", HONEY_DATA["artifact"][str(self.id)][-1])[0])
return {
"icon": f"i_n{first_id + 30}",
"flower": f"i_n{first_id + 30}",
"plume": f"i_n{first_id + 10}",
"sands": f"i_n{first_id + 40}",
"goblet": f"i_n{first_id}",
"circlet": f"i_n{first_id + 20}",
}
class _NamecardAssets(_AssetsService):
enka: EnkaCharacterAsset | None
navbar: ICON_TYPE
"""好友名片背景"""
profile: ICON_TYPE
"""个人资料名片背景"""
NAME_CARD_DEFAULT: int = 210189
"""默认名片 ID"""
@cached_property
def honey_id(self) -> str:
return HONEY_DATA["namecard"][str(self.id)][0]
@cached_property
def game_name(self) -> str:
return NAMECARD_DATA[str(self.id)]["icon"]
@lru_cache
def _get_id_from_avatar_id(self, avatar_id: Union[int, str]) -> int:
avatar_icon_name = AVATAR_DATA[str(avatar_id)]["icon"].split("_")[-1]
fallback = None
for namecard_id, namecard_data in NAMECARD_DATA.items():
if namecard_data["icon"].split("_")[-1] == avatar_icon_name:
return int(namecard_id)
if avatar_icon_name in namecard_data["icon"].split("_")[-1]:
fallback = int(namecard_id)
if fallback:
return fallback
raise ValueError(avatar_id)
def __call__(self, target: int) -> "_NamecardAssets":
if target == 0:
target = self.NAME_CARD_DEFAULT
result = _NamecardAssets(self.client)
target = int(target) if not isinstance(target, int) else target
if target > 10000000:
target = self._get_id_from_avatar_id(target)
result.id = target
result.enka = DEFAULT_EnkaAssets.namecards(target)
return result
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
if item == "profile":
yield AMBR_HOST.join(f"assets/UI/namecard/{self.game_name_map[item]}.png.png")
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
if (url := getattr(self.enka, {"profile": "banner"}.get(item, item), None)) is not None:
yield url.url
@cached_property
def game_name_map(self) -> dict[str, str]:
return {
"icon": self.game_name,
"navbar": NAMECARD_DATA[str(self.id)]["navbar"],
"profile": NAMECARD_DATA[str(self.id)]["profile"],
}
@cached_property
def honey_name_map(self) -> dict[str, str]:
return {
"icon": self.honey_id,
"navbar": f"{self.honey_id}_back",
"profile": f"{self.honey_id}_profile",
}
class AssetsService(BaseService.Dependence):
"""asset服务
用于储存和管理 asset :
当对应的 asset (如某角色图标)不存在时该服务会先查找本地
若本地不存在则从网络上下载若存在则返回其路径
"""
avatar: _AvatarAssets
"""角色"""
weapon: _WeaponAssets
"""武器"""
material: _MaterialAssets
"""素材"""
artifact: _ArtifactAssets
"""圣遗物"""
namecard: _NamecardAssets
"""名片"""
def __init__(self):
for attr, assets_type_name in filter(
lambda x: (not x[0].startswith("_")) and x[1].endswith("Assets"), self.__annotations__.items()
):
setattr(self, attr, globals()[assets_type_name]())
async def initialize(self) -> None: # pylint: disable=R0201
"""启动 AssetsService 服务,刷新元数据"""
logger.info("正在刷新元数据")
# todo 这3个任务同时异步下载
await update_metadata_from_github(False)
await update_metadata_from_ambr(False)
await update_honey_metadata(False)
logger.info("刷新元数据成功")
AssetsServiceType = TypeVar("AssetsServiceType", bound=_AssetsService)

View File

@ -1,175 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from functools import partial
from pathlib import Path
from typing import Awaitable, Callable, ClassVar, TypeVar
from enkanetwork import Assets as EnkaAssets
from enkanetwork.model.assets import CharacterAsset as EnkaCharacterAsset
from httpx import AsyncClient
from typing_extensions import Self
from core.base_service import BaseService
from utils.typedefs import StrOrInt
__all__ = ("AssetsServiceType", "AssetsService", "AssetsServiceError", "AssetsCouldNotFound", "DEFAULT_EnkaAssets")
ICON_TYPE = Callable[[bool], Awaitable[Path | None]] | Callable[..., Awaitable[Path | None]]
DEFAULT_EnkaAssets: EnkaAssets
_GET_TYPE = partial | list[str] | int | str | ICON_TYPE | Path | AsyncClient | None | Self | dict[str, str]
class AssetsServiceError(Exception): ...
class AssetsCouldNotFound(AssetsServiceError):
message: str
target: str
def __init__(self, message: str, target: str): ...
class _AssetsService(ABC):
icon_types: ClassVar[list[str]]
id: int
type: str
icon: ICON_TYPE
"""图标"""
@abstractmethod
@property
def game_name(self) -> str:
"""游戏数据中的名称"""
@property
def honey_id(self) -> str:
"""当前资源在 Honey Impact 所对应的 ID"""
@property
def path(self) -> Path:
"""当前资源的文件夹"""
@property
def client(self) -> AsyncClient:
"""当前的 http client"""
def __init__(self, client: AsyncClient | None = None) -> None: ...
def __call__(self, target: int) -> Self:
"""用于生成与 target 对应的 assets"""
def __getattr__(self, item: str) -> _GET_TYPE:
"""魔法"""
async def get_link(self, item: str) -> str | None:
"""获取相应图标链接"""
@abstractmethod
@property
def game_name_map(self) -> dict[str, str]:
"""游戏中的图标名"""
@abstractmethod
@property
def honey_name_map(self) -> dict[str, str]:
"""来自honey的图标名"""
class _AvatarAssets(_AssetsService):
enka: EnkaCharacterAsset | None
side: ICON_TYPE
"""侧视图图标"""
card: ICON_TYPE
"""卡片图标"""
gacha: ICON_TYPE
"""抽卡立绘"""
gacha_card: ICON_TYPE
"""抽卡卡片"""
@property
def honey_name_map(self) -> dict[str, str]: ...
@property
def game_name_map(self) -> dict[str, str]: ...
@property
def enka(self) -> EnkaCharacterAsset | None: ...
def __init__(self, client: AsyncClient | None = None, enka: EnkaAssets | None = None) -> None: ...
def __call__(self, target: StrOrInt) -> Self: ...
def __getitem__(self, item: str) -> _GET_TYPE | EnkaCharacterAsset: ...
def game_name(self) -> str: ...
class _WeaponAssets(_AssetsService):
awaken: ICON_TYPE
"""突破后图标"""
gacha: ICON_TYPE
"""抽卡立绘"""
@property
def honey_name_map(self) -> dict[str, str]: ...
@property
def game_name_map(self) -> dict[str, str]: ...
def __call__(self, target: StrOrInt) -> Self: ...
def game_name(self) -> str: ...
class _MaterialAssets(_AssetsService):
@property
def honey_name_map(self) -> dict[str, str]: ...
@property
def game_name_map(self) -> dict[str, str]: ...
def __call__(self, target: StrOrInt) -> Self: ...
def game_name(self) -> str: ...
class _ArtifactAssets(_AssetsService):
flower: ICON_TYPE
"""生之花"""
plume: ICON_TYPE
"""死之羽"""
sands: ICON_TYPE
"""时之沙"""
goblet: ICON_TYPE
"""空之杯"""
circlet: ICON_TYPE
"""理之冠"""
@property
def honey_name_map(self) -> dict[str, str]: ...
@property
def game_name_map(self) -> dict[str, str]: ...
def game_name(self) -> str: ...
class _NamecardAssets(_AssetsService):
enka: EnkaCharacterAsset | None
navbar: ICON_TYPE
"""好友名片背景"""
profile: ICON_TYPE
"""个人资料名片背景"""
@property
def honey_name_map(self) -> dict[str, str]: ...
@property
def game_name_map(self) -> dict[str, str]: ...
def game_name(self) -> str: ...
class AssetsService(BaseService.Dependence):
avatar: _AvatarAssets
"""角色"""
weapon: _WeaponAssets
"""武器"""
material: _MaterialAssets
"""素材"""
artifact: _ArtifactAssets
"""圣遗物"""
namecard: _NamecardAssets
"""名片"""
AssetsServiceType = TypeVar("AssetsServiceType", bound=_AssetsService)

View File

@ -1 +0,0 @@
"""GameService"""

View File

@ -1,29 +0,0 @@
from typing import List
from core.base_service import BaseService
from core.dependence.redisdb import RedisDB
__all__ = ["GameCache", "GameCacheForStrategy"]
class GameCache:
qname: str
def __init__(self, redis: RedisDB, ttl: int = 3600):
self.client = redis.client
self.ttl = ttl
async def get_url_list(self, character_name: str):
qname = f"{self.qname}:{character_name}"
return [str(str_data, encoding="utf-8") for str_data in await self.client.lrange(qname, 0, -1)][::-1]
async def set_url_list(self, character_name: str, str_list: List[str]):
qname = f"{self.qname}:{character_name}"
await self.client.ltrim(qname, 1, 0)
await self.client.lpush(qname, *str_list)
await self.client.expire(qname, self.ttl)
return await self.client.llen(qname)
class GameCacheForStrategy(BaseService.Component, GameCache):
qname = "game:strategy"

View File

@ -1,52 +0,0 @@
from typing import List, Optional
from core.base_service import BaseService
from core.services.game.cache import GameCacheForStrategy
from modules.apihelper.client.components.hyperion import Hyperion
__all__ = "GameStrategyService"
class GameStrategyService(BaseService):
def __init__(self, cache: GameCacheForStrategy, collections: Optional[List[int]] = None):
self._cache = cache
self._hyperion = Hyperion()
if collections is None:
self._collections = [2319292, 2319293, 2319294, 2319295, 2319296, 2319298, 2319299]
else:
self._collections = collections
self._special_posts = {"达达利亚": "21272578"}
async def _get_strategy_from_hyperion(self, collection_id: int, character_name: str) -> int:
if character_name in self._special_posts:
return self._special_posts[character_name]
post_id: int = -1
post_full_in_collection = await self._hyperion.get_post_full_in_collection(collection_id)
for post_data in post_full_in_collection["posts"]:
title = post_data["post"]["subject"]
topics = post_data["topics"]
for topic in topics:
if character_name == topic["name"]:
post_id = int(post_data["post"]["post_id"])
break
if post_id == -1 and title and character_name in title:
post_id = int(post_data["post"]["post_id"])
if post_id != -1:
break
return post_id
async def get_strategy(self, character_name: str) -> str:
cache = await self._cache.get_url_list(character_name)
if len(cache) >= 1:
return cache[0]
for collection_id in self._collections:
post_id = await self._get_strategy_from_hyperion(collection_id, character_name)
if post_id != -1:
break
else:
return ""
artwork_info = await self._hyperion.get_post_info(2, post_id)
await self._cache.set_url_list(character_name, artwork_info.image_urls)
return artwork_info.image_urls[0]

View File

@ -1,23 +1,11 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from enkanetwork import (
VaildateUIDError,
HTTPException,
EnkaPlayerNotFound,
PlayerInfo as EnkaPlayerInfo,
TimedOut,
EnkaServerMaintanance,
)
from core.base_service import BaseService from core.base_service import BaseService
from core.config import config
from core.dependence.redisdb import RedisDB from core.dependence.redisdb import RedisDB
from core.services.players.models import PlayersDataBase as Player, PlayerInfoSQLModel, PlayerInfo from core.services.players.models import PlayersDataBase as Player, PlayerInfoSQLModel, PlayerInfo
from core.services.players.repositories import PlayerInfoRepository from core.services.players.repositories import PlayerInfoRepository
from gram_core.services.players.services import PlayersService from gram_core.services.players.services import PlayersService
from utils.enkanetwork import RedisCache, EnkaNetworkAPI
from utils.log import logger
__all__ = ("PlayersService", "PlayerInfoService") __all__ = ("PlayersService", "PlayerInfoService")
@ -26,8 +14,6 @@ class PlayerInfoService(BaseService):
def __init__(self, redis: RedisDB, players_info_repository: PlayerInfoRepository): def __init__(self, redis: RedisDB, players_info_repository: PlayerInfoRepository):
self.cache = redis.client self.cache = redis.client
self._players_info_repository = players_info_repository self._players_info_repository = players_info_repository
self.enka_client = EnkaNetworkAPI(lang="chs", user_agent=config.enka_network_api_agent)
self.enka_client.set_cache(RedisCache(redis.client, key="players_info:enka_network", ex=60))
self.qname = "players_info" self.qname = "players_info"
async def get_form_cache(self, player: Player): async def get_form_cache(self, player: Player):
@ -42,22 +28,7 @@ class PlayerInfoService(BaseService):
qname = f"{self.qname}:{player.user_id}:{player.player_id}" qname = f"{self.qname}:{player.user_id}:{player.player_id}"
await self.cache.set(qname, player.json(), ex=60) await self.cache.set(qname, player.json(), ex=60)
async def get_player_info_from_enka(self, player_id: int) -> Optional[EnkaPlayerInfo]: async def get_player_info_from_enka(self, player_id: int) -> None:
try:
response = await self.enka_client.fetch_user(player_id, info=True)
return response.player
except VaildateUIDError:
logger.warning("Enka.Network 请求失败 UID 不正确")
except EnkaPlayerNotFound:
logger.warning("Enka.Network 请求失败 玩家不存在")
except EnkaServerMaintanance:
logger.warning("Enka.Network 正在进行服务器维护请耐心等待5至8小时或者1天。")
except TimedOut:
logger.warning("Enka.Network 请求超时")
except HTTPException as exc:
logger.warning("Enka.Network 请求失败: %s", str(exc))
except Exception as exc:
logger.error("Enka.Network 请求失败: %s", exc_info=exc)
return None return None
async def get(self, player: Player) -> Optional[PlayerInfo]: async def get(self, player: Player) -> Optional[PlayerInfo]:

View File

@ -1 +0,0 @@
"""QuizService"""

View File

@ -1,69 +0,0 @@
from typing import List
from core.base_service import BaseService
from core.dependence.redisdb import RedisDB
from core.services.quiz.models import Answer, Question
__all__ = ("QuizCache",)
class QuizCache(BaseService.Component):
def __init__(self, redis: RedisDB):
self.client = redis.client
self.question_qname = "quiz:question"
self.answer_qname = "quiz:answer"
async def get_all_question(self) -> List[Question]:
temp_list = []
qname = self.question_qname + "id_list"
data_list = [self.question_qname + f":{question_id}" for question_id in await self.client.lrange(qname, 0, -1)]
data = await self.client.mget(data_list)
for i in data:
temp_list.append(Question.parse_raw(i))
return temp_list
async def get_all_question_id_list(self) -> List[str]:
qname = self.question_qname + ":id_list"
return await self.client.lrange(qname, 0, -1)
async def get_one_question(self, question_id: int) -> Question:
qname = f"{self.question_qname}:{question_id}"
data = await self.client.get(qname)
json_data = str(data, encoding="utf-8")
return Question.parse_raw(json_data)
async def get_one_answer(self, answer_id: int) -> Answer:
qname = f"{self.answer_qname}:{answer_id}"
data = await self.client.get(qname)
json_data = str(data, encoding="utf-8")
return Answer.parse_raw(json_data)
async def add_question(self, question_list: List[Question] = None) -> int:
if not question_list:
return 0
for question in question_list:
await self.client.set(f"{self.question_qname}:{question.question_id}", question.json())
question_id_list = [question.question_id for question in question_list]
await self.client.lpush(f"{self.question_qname}:id_list", *question_id_list)
return await self.client.llen(f"{self.question_qname}:id_list")
async def del_all_question(self):
keys = await self.client.keys(f"{self.question_qname}*")
if keys is not None:
for key in keys:
await self.client.delete(key)
async def del_all_answer(self):
keys = await self.client.keys(f"{self.answer_qname}*")
if keys is not None:
for key in keys:
await self.client.delete(key)
async def add_answer(self, answer_list: List[Answer] = None) -> int:
if not answer_list:
return 0
for answer in answer_list:
await self.client.set(f"{self.answer_qname}:{answer.answer_id}", answer.json())
answer_id_list = [answer.answer_id for answer in answer_list]
await self.client.lpush(f"{self.answer_qname}:id_list", *answer_id_list)
return await self.client.llen(f"{self.answer_qname}:id_list")

View File

@ -1,53 +0,0 @@
from typing import List, Optional
from pydantic import BaseModel
from sqlmodel import Column, Field, ForeignKey, Integer, SQLModel
__all__ = ("Answer", "AnswerDB", "Question", "QuestionDB")
class AnswerDB(SQLModel, table=True):
__tablename__ = "answer"
__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))
question_id: Optional[int] = Field(
sa_column=Column(Integer, ForeignKey("question.id", ondelete="RESTRICT", onupdate="RESTRICT"))
)
is_correct: Optional[bool] = Field()
text: Optional[str] = Field()
class QuestionDB(SQLModel, table=True):
__tablename__ = "question"
__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))
text: Optional[str] = Field()
class Answer(BaseModel):
answer_id: int = 0
question_id: int = 0
is_correct: bool = True
text: str = ""
def to_database_data(self) -> AnswerDB:
return AnswerDB(id=self.answer_id, question_id=self.question_id, text=self.text, is_correct=self.is_correct)
@classmethod
def de_database_data(cls, data: AnswerDB) -> Optional["Answer"]:
return cls(answer_id=data.id, question_id=data.question_id, text=data.text, is_correct=data.is_correct)
class Question(BaseModel):
question_id: int = 0
text: str = ""
answers: List[Answer] = []
def to_database_data(self) -> QuestionDB:
return QuestionDB(text=self.text, id=self.question_id)
@classmethod
def de_database_data(cls, data: QuestionDB) -> Optional["Question"]:
return cls(question_id=data.id, text=data.text)

View File

@ -1,57 +0,0 @@
from typing import List
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from core.base_service import BaseService
from core.dependence.database import Database
from core.services.quiz.models import AnswerDB, QuestionDB
__all__ = ("QuizRepository",)
class QuizRepository(BaseService.Component):
def __init__(self, database: Database):
self.engine = database.engine
async def get_question_list(self) -> List[QuestionDB]:
async with AsyncSession(self.engine) as session:
query = select(QuestionDB)
results = await session.exec(query)
return results.all()
async def get_answers_from_question_id(self, question_id: int) -> List[AnswerDB]:
async with AsyncSession(self.engine) as session:
query = select(AnswerDB).where(AnswerDB.question_id == question_id)
results = await session.exec(query)
return results.all()
async def add_question(self, question: QuestionDB):
async with AsyncSession(self.engine) as session:
session.add(question)
await session.commit()
async def get_question_by_text(self, text: str) -> QuestionDB:
async with AsyncSession(self.engine) as session:
query = select(QuestionDB).where(QuestionDB.text == text)
results = await session.exec(query)
return results.first()
async def add_answer(self, answer: AnswerDB):
async with AsyncSession(self.engine) as session:
session.add(answer)
await session.commit()
async def delete_question_by_id(self, question_id: int):
async with AsyncSession(self.engine) as session:
statement = select(QuestionDB).where(QuestionDB.id == question_id)
results = await session.exec(statement)
question = results.one()
await session.delete(question)
async def delete_answer_by_id(self, answer_id: int):
async with AsyncSession(self.engine) as session:
statement = select(AnswerDB).where(AnswerDB.id == answer_id)
results = await session.exec(statement)
answer = results.one()
await session.delete(answer)

View File

@ -1,64 +0,0 @@
import asyncio
from typing import List
from core.base_service import BaseService
from core.services.quiz.cache import QuizCache
from core.services.quiz.models import Answer, Question
from core.services.quiz.repositories import QuizRepository
__all__ = ("QuizService",)
class QuizService(BaseService):
def __init__(self, repository: QuizRepository, cache: QuizCache):
self._repository = repository
self._cache = cache
self.lock = asyncio.Lock()
async def get_quiz_from_database(self) -> List[Question]:
"""从数据库获取问题列表
:return: Question List
"""
temp: list = []
question_list = await self._repository.get_question_list()
for question in question_list:
question_id = question.id
answers = await self._repository.get_answers_from_question_id(question_id)
data = Question.de_database_data(question)
data.answers = [Answer.de_database_data(a) for a in answers]
temp.append(data)
return temp
async def save_quiz(self, data: Question):
await self._repository.get_question_by_text(data.text)
for answers in data.answers:
await self._repository.add_answer(answers.to_database_data())
async def refresh_quiz(self) -> int:
"""从数据库刷新问题到Redis缓存 线程安全
:return: 已经缓存问题的数量
"""
# 只允许一个线程访问该区域 让数据被安全有效的访问
async with self.lock:
question_list = await self.get_quiz_from_database()
await self._cache.del_all_question()
question_count = await self._cache.add_question(question_list)
await self._cache.del_all_answer()
for question in question_list:
await self._cache.add_answer(question.answers)
return question_count
async def get_question_id_list(self) -> List[int]:
return [int(question_id) for question_id in await self._cache.get_all_question_id_list()]
async def get_answer(self, answer_id: int) -> Answer:
return await self._cache.get_one_answer(answer_id)
async def get_question(self, question_id: int) -> Question:
return await self._cache.get_one_question(question_id)
async def delete_question_by_id(self, question_id: int):
return await self._repository.delete_question_by_id(question_id)
async def delete_answer_by_id(self, answer_id: int):
return await self._repository.delete_answer_by_id(answer_id)

View File

@ -20,6 +20,7 @@ class BaseEntry(BaseModel):
parse_mode: Optional[str] = None parse_mode: Optional[str] = None
photo_url: Optional[str] = None photo_url: Optional[str] = None
photo_file_id: Optional[str] = None photo_file_id: Optional[str] = None
document_file_id: Optional[str] = None
@abstractmethod @abstractmethod
def compare_to_query(self, search_query: str) -> float: def compare_to_query(self, search_query: str) -> float:

View File

@ -1 +0,0 @@
"""WikiService"""

View File

@ -1,41 +0,0 @@
from core.base_service import BaseService
from core.dependence.redisdb import RedisDB
from modules.wiki.base import Model
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
__all__ = ["WikiCache"]
class WikiCache(BaseService.Component):
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "wiki"
async def set(self, key: str, value):
qname = f"{self.qname}:{key}"
if isinstance(value, Model):
value = value.json()
elif isinstance(value, (dict, list)):
value = jsonlib.dumps(value)
await self.client.set(qname, value)
async def delete(self, key: str):
qname = f"{self.qname}:{key}"
await self.client.delete(qname)
async def get(self, key: str) -> dict:
qname = f"{self.qname}:{key}"
# noinspection PyBroadException
try:
result = jsonlib.loads(await self.client.get(qname))
except Exception: # pylint: disable=W0703
result = []
if isinstance(result, list) and len(result) > 0:
for num, item in enumerate(result):
result[num] = jsonlib.loads(item)
return result

View File

@ -1,103 +0,0 @@
from typing import List, Optional
from core.base_service import BaseService
from core.services.wiki.cache import WikiCache
from modules.wiki.character import Character
from modules.wiki.weapon import Weapon
from utils.log import logger
__all__ = ["WikiService"]
class WikiService(BaseService):
def __init__(self, cache: WikiCache):
self._cache = cache
"""Redis 在这里的作用是作为持久化"""
self._character_list = []
self._character_name_list = []
self._weapon_name_list = []
self._weapon_list = []
self.first_run = True
async def refresh_weapon(self):
weapon_name_list = await Weapon.get_name_list()
logger.info("一共找到 %s 把武器信息", len(weapon_name_list))
weapon_list = []
num = 0
async for weapon in Weapon.full_data_generator():
weapon_list.append(weapon)
num += 1
if num % 10 == 0:
logger.info("现在已经获取到 %s 把武器信息", num)
logger.info("写入武器信息到Redis")
self._weapon_list = weapon_list
await self._cache.delete("weapon")
await self._cache.set("weapon", [i.json() for i in weapon_list])
async def refresh_characters(self):
character_name_list = await Character.get_name_list()
logger.info("一共找到 %s 个角色信息", len(character_name_list))
character_list = []
num = 0
async for character in Character.full_data_generator():
character_list.append(character)
num += 1
if num % 10 == 0:
logger.info("现在已经获取到 %s 个角色信息", num)
logger.info("写入角色信息到Redis")
self._character_list = character_list
await self._cache.delete("characters")
await self._cache.set("characters", [i.json() for i in character_list])
async def refresh_wiki(self):
"""
用于把Redis的缓存全部加载进Python
:return:
"""
logger.info("正在重新获取Wiki")
logger.info("正在重新获取武器信息")
await self.refresh_weapon()
logger.info("正在重新获取角色信息")
await self.refresh_characters()
logger.info("刷新成功")
async def init(self):
"""
用于把Redis的缓存全部加载进Python
:return:
"""
if self.first_run:
weapon_dict = await self._cache.get("weapon")
self._weapon_list = [Weapon.parse_obj(obj) for obj in weapon_dict]
self._weapon_name_list = [weapon.name for weapon in self._weapon_list]
characters_dict = await self._cache.get("characters")
self._character_list = [Character.parse_obj(obj) for obj in characters_dict]
self._character_name_list = [character.name for character in self._character_list]
self.first_run = False
async def get_weapons(self, name: str) -> Optional[Weapon]:
await self.init()
if len(self._weapon_list) == 0:
return None
return next((weapon for weapon in self._weapon_list if weapon.name == name), None)
async def get_weapons_name_list(self) -> List[str]:
await self.init()
return self._weapon_name_list
async def get_weapons_list(self) -> List[Weapon]:
await self.init()
return self._weapon_list
async def get_characters_list(self) -> List[Character]:
await self.init()
return self._character_list
async def get_characters_name_list(self) -> List[str]:
await self.init()
return self._character_name_list

495
pdm.lock
View File

@ -5,7 +5,7 @@
groups = ["default", "genshin-artifact", "pyro", "test"] groups = ["default", "genshin-artifact", "pyro", "test"]
strategy = ["cross_platform", "inherit_metadata"] strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.2" lock_version = "4.4.2"
content_hash = "sha256:d7909b325935f473694ce0d17f58f2c7cb59d89b99bcfa6be284426ca92d6254" content_hash = "sha256:a71862798602dbee31c53d2ad5d361d3fa0ebe0b060d0e81857c047ae3002384"
[[package]] [[package]]
name = "aiocsv" name = "aiocsv"
@ -200,7 +200,7 @@ files = [
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.13.1" version = "1.13.2"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "A database migration tool for SQLAlchemy." summary = "A database migration tool for SQLAlchemy."
groups = ["default"] groups = ["default"]
@ -212,8 +212,8 @@ dependencies = [
"typing-extensions>=4", "typing-extensions>=4",
] ]
files = [ files = [
{file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"}, {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"},
{file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"}, {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"},
] ]
[[package]] [[package]]
@ -251,7 +251,7 @@ files = [
[[package]] [[package]]
name = "arko-wrapper" name = "arko-wrapper"
version = "0.2.8" version = "0.3.0"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "给你的Python迭代器加上魔法" summary = "给你的Python迭代器加上魔法"
groups = ["default"] groups = ["default"]
@ -259,8 +259,8 @@ dependencies = [
"typing-extensions", "typing-extensions",
] ]
files = [ files = [
{file = "arko-wrapper-0.2.8.tar.gz", hash = "sha256:85167bc6f1dd48e3415a23a7f2f193c1544a450fd6d219ce28043af796c9b4c3"}, {file = "arko_wrapper-0.3.0-py3-none-any.whl", hash = "sha256:9230d479c5bbdb4b6add8d75def69a8aa5aad4e7057dc92aebe7d0146a4b95d7"},
{file = "arko_wrapper-0.2.8-py3-none-any.whl", hash = "sha256:c56b8cdbbd273cc1b7737667374ee600766e9e7f9f9546871b20912024aa0fb2"}, {file = "arko_wrapper-0.3.0.tar.gz", hash = "sha256:6e37ab1f2dbdd961f07313c3267c5864f5e71b39de730e384003aa2af1f68357"},
] ]
[[package]] [[package]]
@ -441,13 +441,13 @@ files = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2024.6.2" version = "2024.7.4"
requires_python = ">=3.6" requires_python = ">=3.6"
summary = "Python package for providing Mozilla's CA Bundle." summary = "Python package for providing Mozilla's CA Bundle."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
] ]
[[package]] [[package]]
@ -716,7 +716,7 @@ files = [
[[package]] [[package]]
name = "fakeredis" name = "fakeredis"
version = "2.23.2" version = "2.23.3"
requires_python = "<4.0,>=3.7" requires_python = "<4.0,>=3.7"
summary = "Python implementation of redis API, can be used for testing purposes." summary = "Python implementation of redis API, can be used for testing purposes."
groups = ["default"] groups = ["default"]
@ -726,8 +726,8 @@ dependencies = [
"typing-extensions<5.0,>=4.7; python_version < \"3.11\"", "typing-extensions<5.0,>=4.7; python_version < \"3.11\"",
] ]
files = [ files = [
{file = "fakeredis-2.23.2-py3-none-any.whl", hash = "sha256:3721946b955930c065231befd24a9cdc68b339746e93848ef01a010d98e4eb4f"}, {file = "fakeredis-2.23.3-py3-none-any.whl", hash = "sha256:4779be828f4ebf53e1a286fd11e2ffe0f510d3e5507f143d644e67a07387d759"},
{file = "fakeredis-2.23.2.tar.gz", hash = "sha256:d649c409abe46c63690b6c35d3c460e4ce64c69a52cea3f02daff2649378f878"}, {file = "fakeredis-2.23.3.tar.gz", hash = "sha256:0c67caa31530114f451f012eca920338c5eb83fa7f1f461dd41b8d2488a99cba"},
] ]
[[package]] [[package]]
@ -865,17 +865,6 @@ files = [
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
] ]
[[package]]
name = "gcsim-pypi"
version = "2.23.0"
requires_python = "<4.0,>=3.6"
summary = "gcsim binary for pypi"
groups = ["default"]
files = [
{file = "gcsim_pypi-2.23.0-py3-none-any.whl", hash = "sha256:260b0fab047adc4ac69fc3380284d6635ca390c4c9de07f1a69a477764a40e4d"},
{file = "gcsim_pypi-2.23.0.tar.gz", hash = "sha256:cff31d76d06ee020254ecdb3990af63108f2498547b976337036372c3310d946"},
]
[[package]] [[package]]
name = "gitdb" name = "gitdb"
version = "4.0.11" version = "4.0.11"
@ -1480,7 +1469,7 @@ files = [
[[package]] [[package]]
name = "openpyxl" name = "openpyxl"
version = "3.1.4" version = "3.1.5"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "A Python library to read/write Excel 2010 xlsx/xlsm files" summary = "A Python library to read/write Excel 2010 xlsx/xlsm files"
groups = ["default"] groups = ["default"]
@ -1488,63 +1477,68 @@ dependencies = [
"et-xmlfile", "et-xmlfile",
] ]
files = [ files = [
{file = "openpyxl-3.1.4-py2.py3-none-any.whl", hash = "sha256:ec17f6483f2b8f7c88c57e5e5d3b0de0e3fb9ac70edc084d28e864f5b33bbefd"}, {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"},
{file = "openpyxl-3.1.4.tar.gz", hash = "sha256:8d2c8adf5d20d6ce8f9bca381df86b534835e974ed0156dacefa76f68c1d69fb"}, {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"},
] ]
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.10.5" version = "3.10.6"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" summary = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"}, {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"}, {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"}, {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"}, {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"}, {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"},
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"}, {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"},
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"}, {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"},
{file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"}, {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"},
{file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"}, {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"},
{file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"}, {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"}, {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"}, {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"}, {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"}, {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"},
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"}, {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"},
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"}, {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"},
{file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"}, {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"},
{file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"}, {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"},
{file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"}, {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"}, {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"}, {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"}, {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"}, {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"},
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"}, {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"},
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"}, {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"},
{file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"}, {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"},
{file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"}, {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"},
{file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"}, {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"}, {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"}, {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"}, {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"},
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"},
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"},
{file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"},
{file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"},
{file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"}, {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"}, {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"}, {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"}, {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"}, {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"},
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"}, {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"},
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"}, {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"},
{file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"}, {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"},
{file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"}, {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"},
{file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"}, {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"},
{file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"},
{file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"},
{file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"},
{file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"},
{file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"},
] ]
[[package]] [[package]]
@ -1571,80 +1565,91 @@ files = [
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "10.3.0" version = "10.4.0"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Python Imaging Library (Fork)" summary = "Python Imaging Library (Fork)"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
{file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
{file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
{file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
{file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
{file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
{file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
{file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
{file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
{file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
{file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
{file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
{file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
{file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
{file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
{file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
{file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
{file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
{file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
{file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
{file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
{file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
{file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
{file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
{file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
{file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
{file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
{file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
{file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
{file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
{file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
{file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
{file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
{file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
{file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
{file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
{file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
{file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
{file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
{file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
{file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
{file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
{file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
{file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
{file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
{file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
{file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
{file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
{file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
{file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
{file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
{file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
{file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
{file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
{file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
{file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
{file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
{file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
] ]
[[package]] [[package]]
@ -2021,104 +2026,104 @@ files = [
[[package]] [[package]]
name = "rapidfuzz" name = "rapidfuzz"
version = "3.9.3" version = "3.9.4"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "rapid fuzzy string matching" summary = "rapid fuzzy string matching"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "rapidfuzz-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bdb8c5b8e29238ec80727c2ba3b301efd45aa30c6a7001123a6647b8e6f77ea4"}, {file = "rapidfuzz-3.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9b9793c19bdf38656c8eaefbcf4549d798572dadd70581379e666035c9df781"},
{file = "rapidfuzz-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3bd0d9632088c63a241f217742b1cf86e2e8ae573e01354775bd5016d12138c"}, {file = "rapidfuzz-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:015b5080b999404fe06ec2cb4f40b0be62f0710c926ab41e82dfbc28e80675b4"},
{file = "rapidfuzz-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:153f23c03d4917f6a1fc2fb56d279cc6537d1929237ff08ee7429d0e40464a18"}, {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc5ceca9c1e1663f3e6c23fb89a311f69b7615a40ddd7645e3435bf3082688a"},
{file = "rapidfuzz-3.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a96c5225e840f1587f1bac8fa6f67562b38e095341576e82b728a82021f26d62"}, {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1424e238bc3f20e1759db1e0afb48a988a9ece183724bef91ea2a291c0b92a95"},
{file = "rapidfuzz-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b777cd910ceecd738adc58593d6ed42e73f60ad04ecdb4a841ae410b51c92e0e"}, {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed01378f605aa1f449bee82cd9c83772883120d6483e90aa6c5a4ce95dc5c3aa"},
{file = "rapidfuzz-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53e06e4b81f552da04940aa41fc556ba39dee5513d1861144300c36c33265b76"}, {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb26d412271e5a76cdee1c2d6bf9881310665d3fe43b882d0ed24edfcb891a84"},
{file = "rapidfuzz-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c7ca5b6050f18fdcacdada2dc5fb7619ff998cd9aba82aed2414eee74ebe6cd"}, {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f37e9e1f17be193c41a31c864ad4cd3ebd2b40780db11cd5c04abf2bcf4201b"},
{file = "rapidfuzz-3.9.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:87bb8d84cb41446a808c4b5f746e29d8a53499381ed72f6c4e456fe0f81c80a8"}, {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d070ec5cf96b927c4dc5133c598c7ff6db3b833b363b2919b13417f1002560bc"},
{file = "rapidfuzz-3.9.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:959a15186d18425d19811bea86a8ffbe19fd48644004d29008e636631420a9b7"}, {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:10e61bb7bc807968cef09a0e32ce253711a2d450a4dce7841d21d45330ffdb24"},
{file = "rapidfuzz-3.9.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a24603dd05fb4e3c09d636b881ce347e5f55f925a6b1b4115527308a323b9f8e"}, {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:31a2fc60bb2c7face4140010a7aeeafed18b4f9cdfa495cc644a68a8c60d1ff7"},
{file = "rapidfuzz-3.9.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d055da0e801c71dd74ba81d72d41b2fa32afa182b9fea6b4b199d2ce937450d"}, {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fbebf1791a71a2e89f5c12b78abddc018354d5859e305ec3372fdae14f80a826"},
{file = "rapidfuzz-3.9.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:875b581afb29a7213cf9d98cb0f98df862f1020bce9d9b2e6199b60e78a41d14"}, {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:aee9fc9e3bb488d040afc590c0a7904597bf4ccd50d1491c3f4a5e7e67e6cd2c"},
{file = "rapidfuzz-3.9.3-cp310-cp310-win32.whl", hash = "sha256:6073a46f61479a89802e3f04655267caa6c14eb8ac9d81a635a13805f735ebc1"}, {file = "rapidfuzz-3.9.4-cp310-cp310-win32.whl", hash = "sha256:005a02688a51c7d2451a2d41c79d737aa326ff54167211b78a383fc2aace2c2c"},
{file = "rapidfuzz-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:119c010e20e561249b99ca2627f769fdc8305b07193f63dbc07bca0a6c27e892"}, {file = "rapidfuzz-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:3a2e75e41ee3274754d3b2163cc6c82cd95b892a85ab031f57112e09da36455f"},
{file = "rapidfuzz-3.9.3-cp310-cp310-win_arm64.whl", hash = "sha256:790b0b244f3213581d42baa2fed8875f9ee2b2f9b91f94f100ec80d15b140ba9"}, {file = "rapidfuzz-3.9.4-cp310-cp310-win_arm64.whl", hash = "sha256:2c99d355f37f2b289e978e761f2f8efeedc2b14f4751d9ff7ee344a9a5ca98d9"},
{file = "rapidfuzz-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f57e8305c281e8c8bc720515540e0580355100c0a7a541105c6cafc5de71daae"}, {file = "rapidfuzz-3.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:07141aa6099e39d48637ce72a25b893fc1e433c50b3e837c75d8edf99e0c63e1"},
{file = "rapidfuzz-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a4fc7b784cf987dbddc300cef70e09a92ed1bce136f7bb723ea79d7e297fe76d"}, {file = "rapidfuzz-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db1664eaff5d7d0f2542dd9c25d272478deaf2c8412e4ad93770e2e2d828e175"},
{file = "rapidfuzz-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b422c0a6fe139d5447a0766268e68e6a2a8c2611519f894b1f31f0a392b9167"}, {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc01a223f6605737bec3202e94dcb1a449b6c76d46082cfc4aa980f2a60fd40e"},
{file = "rapidfuzz-3.9.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f50fed4a9b0c9825ff37cf0bccafd51ff5792090618f7846a7650f21f85579c9"}, {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1869c42e73e2a8910b479be204fa736418741b63ea2325f9cc583c30f2ded41a"},
{file = "rapidfuzz-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b80eb7cbe62348c61d3e67e17057cddfd6defab168863028146e07d5a8b24a89"}, {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62ea7007941fb2795fff305ac858f3521ec694c829d5126e8f52a3e92ae75526"},
{file = "rapidfuzz-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f45be77ec82da32ce5709a362e236ccf801615cc7163b136d1778cf9e31b14"}, {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:698e992436bf7f0afc750690c301215a36ff952a6dcd62882ec13b9a1ebf7a39"},
{file = "rapidfuzz-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd84b7f652a5610733400307dc732f57c4a907080bef9520412e6d9b55bc9adc"}, {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b76f611935f15a209d3730c360c56b6df8911a9e81e6a38022efbfb96e433bab"},
{file = "rapidfuzz-3.9.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e6d27dad8c990218b8cd4a5c99cbc8834f82bb46ab965a7265d5aa69fc7ced7"}, {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129627d730db2e11f76169344a032f4e3883d34f20829419916df31d6d1338b1"},
{file = "rapidfuzz-3.9.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:05ee0696ebf0dfe8f7c17f364d70617616afc7dafe366532730ca34056065b8a"}, {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:90a82143c14e9a14b723a118c9ef8d1bbc0c5a16b1ac622a1e6c916caff44dd8"},
{file = "rapidfuzz-3.9.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2bc8391749e5022cd9e514ede5316f86e332ffd3cfceeabdc0b17b7e45198a8c"}, {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ded58612fe3b0e0d06e935eaeaf5a9fd27da8ba9ed3e2596307f40351923bf72"},
{file = "rapidfuzz-3.9.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:93981895602cf5944d89d317ae3b1b4cc684d175a8ae2a80ce5b65615e72ddd0"}, {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f16f5d1c4f02fab18366f2d703391fcdbd87c944ea10736ca1dc3d70d8bd2d8b"},
{file = "rapidfuzz-3.9.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:754b719a4990735f66653c9e9261dcf52fd4d925597e43d6b9069afcae700d21"}, {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:26aa7eece23e0df55fb75fbc2a8fb678322e07c77d1fd0e9540496e6e2b5f03e"},
{file = "rapidfuzz-3.9.3-cp311-cp311-win32.whl", hash = "sha256:14c9f268ade4c88cf77ab007ad0fdf63699af071ee69378de89fff7aa3cae134"}, {file = "rapidfuzz-3.9.4-cp311-cp311-win32.whl", hash = "sha256:f187a9c3b940ce1ee324710626daf72c05599946bd6748abe9e289f1daa9a077"},
{file = "rapidfuzz-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc1991b4cde6c9d3c0bbcb83d5581dc7621bec8c666c095c65b4277233265a82"}, {file = "rapidfuzz-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8e9130fe5d7c9182990b366ad78fd632f744097e753e08ace573877d67c32f8"},
{file = "rapidfuzz-3.9.3-cp311-cp311-win_arm64.whl", hash = "sha256:0c34139df09a61b1b557ab65782ada971b4a3bce7081d1b2bee45b0a52231adb"}, {file = "rapidfuzz-3.9.4-cp311-cp311-win_arm64.whl", hash = "sha256:40419e98b10cd6a00ce26e4837a67362f658fc3cd7a71bd8bd25c99f7ee8fea5"},
{file = "rapidfuzz-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5d6a210347d6e71234af5c76d55eeb0348b026c9bb98fe7c1cca89bac50fb734"}, {file = "rapidfuzz-3.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b5d5072b548db1b313a07d62d88fe0b037bd2783c16607c647e01b070f6cf9e5"},
{file = "rapidfuzz-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b300708c917ce52f6075bdc6e05b07c51a085733650f14b732c087dc26e0aaad"}, {file = "rapidfuzz-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf5bcf22e1f0fd273354462631d443ef78d677f7d2fc292de2aec72ae1473e66"},
{file = "rapidfuzz-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83ea7ca577d76778250421de61fb55a719e45b841deb769351fc2b1740763050"}, {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c8fc973adde8ed52810f590410e03fb6f0b541bbaeb04c38d77e63442b2df4c"},
{file = "rapidfuzz-3.9.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8319838fb5b7b5f088d12187d91d152b9386ce3979ed7660daa0ed1bff953791"}, {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2464bb120f135293e9a712e342c43695d3d83168907df05f8c4ead1612310c7"},
{file = "rapidfuzz-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:505d99131afd21529293a9a7b91dfc661b7e889680b95534756134dc1cc2cd86"}, {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d9d58689aca22057cf1a5851677b8a3ccc9b535ca008c7ed06dc6e1899f7844"},
{file = "rapidfuzz-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c52970f7784518d7c82b07a62a26e345d2de8c2bd8ed4774e13342e4b3ff4200"}, {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:167e745f98baa0f3034c13583e6302fb69249a01239f1483d68c27abb841e0a1"},
{file = "rapidfuzz-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:143caf7247449055ecc3c1e874b69e42f403dfc049fc2f3d5f70e1daf21c1318"}, {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db0bf0663b4b6da1507869722420ea9356b6195aa907228d6201303e69837af9"},
{file = "rapidfuzz-3.9.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b8ab0fa653d9225195a8ff924f992f4249c1e6fa0aea563f685e71b81b9fcccf"}, {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd6ac61b74fdb9e23f04d5f068e6cf554f47e77228ca28aa2347a6ca8903972f"},
{file = "rapidfuzz-3.9.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57e7c5bf7b61c7320cfa5dde1e60e678d954ede9bb7da8e763959b2138391401"}, {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:60ff67c690acecf381759c16cb06c878328fe2361ddf77b25d0e434ea48a29da"},
{file = "rapidfuzz-3.9.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:51fa1ba84653ab480a2e2044e2277bd7f0123d6693051729755addc0d015c44f"}, {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cb934363380c60f3a57d14af94325125cd8cded9822611a9f78220444034e36e"},
{file = "rapidfuzz-3.9.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:17ff7f7eecdb169f9236e3b872c96dbbaf116f7787f4d490abd34b0116e3e9c8"}, {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fe833493fb5cc5682c823ea3e2f7066b07612ee8f61ecdf03e1268f262106cdd"},
{file = "rapidfuzz-3.9.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:afe7c72d3f917b066257f7ff48562e5d462d865a25fbcabf40fca303a9fa8d35"}, {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2797fb847d89e04040d281cb1902cbeffbc4b5131a5c53fc0db490fd76b2a547"},
{file = "rapidfuzz-3.9.3-cp312-cp312-win32.whl", hash = "sha256:e53ed2e9b32674ce96eed80b3b572db9fd87aae6742941fb8e4705e541d861ce"}, {file = "rapidfuzz-3.9.4-cp312-cp312-win32.whl", hash = "sha256:52e3d89377744dae68ed7c84ad0ddd3f5e891c82d48d26423b9e066fc835cc7c"},
{file = "rapidfuzz-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:35b7286f177e4d8ba1e48b03612f928a3c4bdac78e5651379cec59f95d8651e6"}, {file = "rapidfuzz-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:c76da20481c906e08400ee9be230f9e611d5931a33707d9df40337c2655c84b5"},
{file = "rapidfuzz-3.9.3-cp312-cp312-win_arm64.whl", hash = "sha256:e6e4b9380ed4758d0cb578b0d1970c3f32dd9e87119378729a5340cb3169f879"}, {file = "rapidfuzz-3.9.4-cp312-cp312-win_arm64.whl", hash = "sha256:f2d2846f3980445864c7e8b8818a29707fcaff2f0261159ef6b7bd27ba139296"},
{file = "rapidfuzz-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a39890013f6d5b056cc4bfdedc093e322462ece1027a57ef0c636537bdde7531"}, {file = "rapidfuzz-3.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:355fc4a268ffa07bab88d9adee173783ec8d20136059e028d2a9135c623c44e6"},
{file = "rapidfuzz-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b5bc0fdbf419493163c5c9cb147c5fbe95b8e25844a74a8807dcb1a125e630cf"}, {file = "rapidfuzz-3.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4d81a78f90269190b568a8353d4ea86015289c36d7e525cd4d43176c88eff429"},
{file = "rapidfuzz-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efe6e200a75a792d37b960457904c4fce7c928a96ae9e5d21d2bd382fe39066e"}, {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e618625ffc4660b26dc8e56225f8b966d5842fa190e70c60db6cd393e25b86e"},
{file = "rapidfuzz-3.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de077c468c225d4c18f7188c47d955a16d65f21aab121cbdd98e3e2011002c37"}, {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b712336ad6f2bacdbc9f1452556e8942269ef71f60a9e6883ef1726b52d9228a"},
{file = "rapidfuzz-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f917eaadf5388466a95f6a236f678a1588d231e52eda85374077101842e794e"}, {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc1ee19fdad05770c897e793836c002344524301501d71ef2e832847425707"},
{file = "rapidfuzz-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858ba57c05afd720db8088a8707079e8d024afe4644001fe0dbd26ef7ca74a65"}, {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1950f8597890c0c707cb7e0416c62a1cf03dcdb0384bc0b2dbda7e05efe738ec"},
{file = "rapidfuzz-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d36447d21b05f90282a6f98c5a33771805f9222e5d0441d03eb8824e33e5bbb4"}, {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a6c35f272ec9c430568dc8c1c30cb873f6bc96be2c79795e0bce6db4e0e101d"},
{file = "rapidfuzz-3.9.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:acbe4b6f1ccd5b90c29d428e849aa4242e51bb6cab0448d5f3c022eb9a25f7b1"}, {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1df0f9e9239132a231c86ae4f545ec2b55409fa44470692fcfb36b1bd00157ad"},
{file = "rapidfuzz-3.9.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:53c7f27cdf899e94712972237bda48cfd427646aa6f5d939bf45d084780e4c16"}, {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d2c51955329bfccf99ae26f63d5928bf5be9fcfcd9f458f6847fd4b7e2b8986c"},
{file = "rapidfuzz-3.9.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6175682a829c6dea4d35ed707f1dadc16513270ef64436568d03b81ccb6bdb74"}, {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:3c522f462d9fc504f2ea8d82e44aa580e60566acc754422c829ad75c752fbf8d"},
{file = "rapidfuzz-3.9.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:5276df395bd8497397197fca2b5c85f052d2e6a66ffc3eb0544dd9664d661f95"}, {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:d8a52fc50ded60d81117d7647f262c529659fb21d23e14ebfd0b35efa4f1b83d"},
{file = "rapidfuzz-3.9.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:77b5c4f3e72924d7845f0e189c304270066d0f49635cf8a3938e122c437e58de"}, {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:04dbdfb0f0bfd3f99cf1e9e24fadc6ded2736d7933f32f1151b0f2abb38f9a25"},
{file = "rapidfuzz-3.9.3-cp38-cp38-win32.whl", hash = "sha256:8add34061e5cd561c72ed4febb5c15969e7b25bda2bb5102d02afc3abc1f52d0"}, {file = "rapidfuzz-3.9.4-cp38-cp38-win32.whl", hash = "sha256:4968c8bd1df84b42f382549e6226710ad3476f976389839168db3e68fd373298"},
{file = "rapidfuzz-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:604e0502a39cf8e67fa9ad239394dddad4cdef6d7008fdb037553817d420e108"}, {file = "rapidfuzz-3.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:3fe4545f89f8d6c27b6bbbabfe40839624873c08bd6700f63ac36970a179f8f5"},
{file = "rapidfuzz-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21047f55d674614eb4b0ab34e35c3dc66f36403b9fbfae645199c4a19d4ed447"}, {file = "rapidfuzz-3.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9f256c8fb8f3125574c8c0c919ab0a1f75d7cba4d053dda2e762dcc36357969d"},
{file = "rapidfuzz-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a56da3aff97cb56fe85d9ca957d1f55dbac7c27da927a86a2a86d8a7e17f80aa"}, {file = "rapidfuzz-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5fdc09cf6e9d8eac3ce48a4615b3a3ee332ea84ac9657dbbefef913b13e632f"},
{file = "rapidfuzz-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:964c08481aec2fe574f0062e342924db2c6b321391aeb73d68853ed42420fd6d"}, {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d395d46b80063d3b5d13c0af43d2c2cedf3ab48c6a0c2aeec715aa5455b0c632"},
{file = "rapidfuzz-3.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e2b827258beefbe5d3f958243caa5a44cf46187eff0c20e0b2ab62d1550327a"}, {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa714fb96ce9e70c37e64c83b62fe8307030081a0bfae74a76fac7ba0f91715"},
{file = "rapidfuzz-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6e65a301fcd19fbfbee3a514cc0014ff3f3b254b9fd65886e8a9d6957fb7bca"}, {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc1a0f29f9119be7a8d3c720f1d2068317ae532e39e4f7f948607c3a6de8396"},
{file = "rapidfuzz-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe93ba1725a8d47d2b9dca6c1f435174859427fbc054d83de52aea5adc65729"}, {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6022674aa1747d6300f699cd7c54d7dae89bfe1f84556de699c4ac5df0838082"},
{file = "rapidfuzz-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca21c0a34adee582775da997a600283e012a608a107398d80a42f9a57ad323d"}, {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb72e5f9762fd469701a7e12e94b924af9004954f8c739f925cb19c00862e38"},
{file = "rapidfuzz-3.9.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:256e07d3465173b2a91c35715a2277b1ee3ae0b9bbab4e519df6af78570741d0"}, {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad04ae301129f0eb5b350a333accd375ce155a0c1cec85ab0ec01f770214e2e4"},
{file = "rapidfuzz-3.9.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:802ca2cc8aa6b8b34c6fdafb9e32540c1ba05fca7ad60b3bbd7ec89ed1797a87"}, {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f46a22506f17c0433e349f2d1dc11907c393d9b3601b91d4e334fa9a439a6a4d"},
{file = "rapidfuzz-3.9.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:dd789100fc852cffac1449f82af0da139d36d84fd9faa4f79fc4140a88778343"}, {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:01b42a8728c36011718da409aa86b84984396bf0ca3bfb6e62624f2014f6022c"},
{file = "rapidfuzz-3.9.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:5d0abbacdb06e27ff803d7ae0bd0624020096802758068ebdcab9bd49cf53115"}, {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e590d5d5443cf56f83a51d3c4867bd1f6be8ef8cfcc44279522bcef3845b2a51"},
{file = "rapidfuzz-3.9.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:378d1744828e27490a823fc6fe6ebfb98c15228d54826bf4e49e4b76eb5f5579"}, {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4c72078b5fdce34ba5753f9299ae304e282420e6455e043ad08e4488ca13a2b0"},
{file = "rapidfuzz-3.9.3-cp39-cp39-win32.whl", hash = "sha256:5d0cb272d43e6d3c0dedefdcd9d00007471f77b52d2787a4695e9dd319bb39d2"}, {file = "rapidfuzz-3.9.4-cp39-cp39-win32.whl", hash = "sha256:f75639277304e9b75e6a7b3c07042d2264e16740a11e449645689ed28e9c2124"},
{file = "rapidfuzz-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:15e4158ac4b3fb58108072ec35b8a69165f651ba1c8f43559a36d518dbf9fb3f"}, {file = "rapidfuzz-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:e81e27e8c32a1e1278a4bb1ce31401bfaa8c2cc697a053b985a6f8d013df83ec"},
{file = "rapidfuzz-3.9.3-cp39-cp39-win_arm64.whl", hash = "sha256:58c6a4936190c558d5626b79fc9e16497e5df7098589a7e80d8bff68148ff096"}, {file = "rapidfuzz-3.9.4-cp39-cp39-win_arm64.whl", hash = "sha256:15bc397ee9a3ed1210b629b9f5f1da809244adc51ce620c504138c6e7095b7bd"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5410dc848c947a603792f4f51b904a3331cf1dc60621586bfbe7a6de72da1091"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:20488ade4e1ddba3cfad04f400da7a9c1b91eff5b7bd3d1c50b385d78b587f4f"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:282d55700a1a3d3a7980746eb2fcd48c9bbc1572ebe0840d0340d548a54d01fe"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:e61b03509b1a6eb31bc5582694f6df837d340535da7eba7bedb8ae42a2fcd0b9"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc1037507810833646481f5729901a154523f98cbebb1157ba3a821012e16402"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:098d231d4e51644d421a641f4a5f2f151f856f53c252b03516e01389b2bfef99"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e33f779391caedcba2ba3089fb6e8e557feab540e9149a5c3f7fea7a3a7df37"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17ab8b7d10fde8dd763ad428aa961c0f30a1b44426e675186af8903b5d134fb0"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41a81a9f311dc83d22661f9b1a1de983b201322df0c4554042ffffd0f2040c37"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e272df61bee0a056a3daf99f9b1bd82cf73ace7d668894788139c868fdf37d6f"},
{file = "rapidfuzz-3.9.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a93250bd8fae996350c251e1752f2c03335bb8a0a5b0c7e910a593849121a435"}, {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d6481e099ff8c4edda85b8b9b5174c200540fd23c8f38120016c765a86fa01f5"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3617d1aa7716c57d120b6adc8f7c989f2d65bc2b0cbd5f9288f1fc7bf469da11"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ad61676e9bdae677d577fe80ec1c2cea1d150c86be647e652551dcfe505b1113"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad04a3f5384b82933213bba2459f6424decc2823df40098920856bdee5fd6e88"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:af65020c0dd48d0d8ae405e7e69b9d8ae306eb9b6249ca8bf511a13f465fad85"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8709918da8a88ad73c9d4dd0ecf24179a4f0ceba0bee21efc6ea21a8b5290349"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d38b4e026fcd580e0bda6c0ae941e0e9a52c6bc66cdce0b8b0da61e1959f5f8"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b770f85eab24034e6ef7df04b2bfd9a45048e24f8a808e903441aa5abde8ecdd"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f74ed072c2b9dc6743fb19994319d443a4330b0e64aeba0aa9105406c7c5b9c2"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930b4e6fdb4d914390141a2b99a6f77a52beacf1d06aa4e170cba3a98e24c1bc"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee5f6b8321f90615c184bd8a4c676e9becda69b8e4e451a90923db719d6857c"},
{file = "rapidfuzz-3.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c8444e921bfc3757c475c4f4d7416a7aa69b2d992d5114fe55af21411187ab0d"}, {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3a555e3c841d6efa350f862204bb0a3fea0c006b8acc9b152b374fa36518a1c6"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c1d3ef3878f871abe6826e386c3d61b5292ef5f7946fe646f4206b85836b5da"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0772150d37bf018110351c01d032bf9ab25127b966a29830faa8ad69b7e2f651"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:d861bf326ee7dabc35c532a40384541578cd1ec1e1b7db9f9ecbba56eb76ca22"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:addcdd3c3deef1bd54075bd7aba0a6ea9f1d01764a08620074b7a7b1e5447cb9"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cde6b9d9ba5007077ee321ec722fa714ebc0cbd9a32ccf0f4dd3cc3f20952d71"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fe86b82b776554add8f900b6af202b74eb5efe8f25acdb8680a5c977608727f"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb6546e7b6bed1aefbe24f68a5fb9b891cc5aef61bca6c1a7b1054b7f0359bb"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0fc91ac59f4414d8542454dfd6287a154b8e6f1256718c898f695bdbb993467"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d8a57261ef7996d5ced7c8cba9189ada3fbeffd1815f70f635e4558d93766cb"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a944e546a296a5fdcaabb537b01459f1b14d66f74e584cb2a91448bffadc3c1"},
{file = "rapidfuzz-3.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:67201c02efc596923ad950519e0b75ceb78d524177ea557134d6567b9ac2c283"}, {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fb96ba96d58c668a17a06b5b5e8340fedc26188e87b0d229d38104556f30cd8"},
{file = "rapidfuzz-3.9.3.tar.gz", hash = "sha256:b398ea66e8ed50451bce5997c430197d5e4b06ac4aa74602717f792d8d8d06e2"}, {file = "rapidfuzz-3.9.4.tar.gz", hash = "sha256:366bf8947b84e37f2f4cf31aaf5f37c39f620d8c0eddb8b633e6ba0129ca4a0a"},
] ]
[[package]] [[package]]
@ -2167,7 +2172,7 @@ files = [
[[package]] [[package]]
name = "sentry-sdk" name = "sentry-sdk"
version = "2.7.0" version = "2.7.1"
requires_python = ">=3.6" requires_python = ">=3.6"
summary = "Python client for Sentry (https://sentry.io)" summary = "Python client for Sentry (https://sentry.io)"
groups = ["default"] groups = ["default"]
@ -2176,19 +2181,19 @@ dependencies = [
"urllib3>=1.26.11", "urllib3>=1.26.11",
] ]
files = [ files = [
{file = "sentry_sdk-2.7.0-py2.py3-none-any.whl", hash = "sha256:db9594c27a4d21c1ebad09908b1f0dc808ef65c2b89c1c8e7e455143262e37c1"}, {file = "sentry_sdk-2.7.1-py2.py3-none-any.whl", hash = "sha256:ef1b3d54eb715825657cd4bb3cb42bb4dc85087bac14c56b0fd8c21abd968c9a"},
{file = "sentry_sdk-2.7.0.tar.gz", hash = "sha256:d846a211d4a0378b289ced3c434480945f110d0ede00450ba631fc2852e7a0d4"}, {file = "sentry_sdk-2.7.1.tar.gz", hash = "sha256:25006c7e68b75aaa5e6b9c6a420ece22e8d7daec4b7a906ffd3a8607b67c037b"},
] ]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "70.1.1" version = "70.2.0"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Easily download, build, install, upgrade, and uninstall Python packages" summary = "Easily download, build, install, upgrade, and uninstall Python packages"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "setuptools-70.1.1-py3-none-any.whl", hash = "sha256:a58a8fde0541dab0419750bcc521fbdf8585f6e5cb41909df3a472ef7b81ca95"}, {file = "setuptools-70.2.0-py3-none-any.whl", hash = "sha256:b8b8060bb426838fbe942479c90296ce976249451118ef566a5a0b7d8b78fb05"},
{file = "setuptools-70.1.1.tar.gz", hash = "sha256:937a48c7cdb7a21eb53cd7f9b59e525503aa8abaf3584c730dc5f7a5bec3a650"}, {file = "setuptools-70.2.0.tar.gz", hash = "sha256:bd63e505105011b25c3c11f753f7e3b8465ea739efddaccef8f0efac2137bac1"},
] ]
[[package]] [[package]]
@ -2207,7 +2212,7 @@ name = "simnet"
version = "0.1.22" version = "0.1.22"
requires_python = "<4.0,>=3.8" requires_python = "<4.0,>=3.8"
git = "https://github.com/PaiGramTeam/SIMNet" git = "https://github.com/PaiGramTeam/SIMNet"
revision = "277a33321a20909541b46bf4ecf794fd47e19fb1" revision = "05fcb568d6c1fe44a4f917c996198bfe62a00053"
summary = "Modern API wrapper for Genshin Impact & Honkai: Star Rail built on asyncio and pydantic." summary = "Modern API wrapper for Genshin Impact & Honkai: Star Rail built on asyncio and pydantic."
groups = ["default"] groups = ["default"]
dependencies = [ dependencies = [

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from simnet import GenshinClient, Region from simnet import ZZZClient, Region
from simnet.errors import ( from simnet.errors import (
InvalidCookies, InvalidCookies,
BadRequest as SimnetBadRequest, BadRequest as SimnetBadRequest,
@ -149,15 +149,15 @@ class BindAccountPlugin(Plugin.Conversation):
) )
return ConversationHandler.END return ConversationHandler.END
if region == RegionEnum.HYPERION: if region == RegionEnum.HYPERION:
client = GenshinClient(cookies=cookies.data, region=Region.CHINESE) client = ZZZClient(cookies=cookies.data, region=Region.CHINESE)
elif region == RegionEnum.HOYOLAB: elif region == RegionEnum.HOYOLAB:
client = GenshinClient(cookies=cookies.data, region=Region.OVERSEAS, lang="zh-cn") client = ZZZClient(cookies=cookies.data, region=Region.OVERSEAS, lang="zh-cn")
else: else:
return ConversationHandler.END return ConversationHandler.END
try: try:
record_card = await client.get_record_card(account_id) record_card = await client.get_record_card(account_id)
if record_card is None: if record_card is None:
await message.reply_text("请在设置展示主界面添加原神", reply_markup=ReplyKeyboardRemove()) await message.reply_text("请在设置展示主界面添加绝区零", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END return ConversationHandler.END
except DataNotPublic: except DataNotPublic:
await message.reply_text("角色未公开", reply_markup=ReplyKeyboardRemove()) await message.reply_text("角色未公开", reply_markup=ReplyKeyboardRemove())

View File

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
from arkowrapper import ArkoWrapper from arkowrapper import ArkoWrapper
from simnet import GenshinClient, Region from simnet import ZZZClient, Region
from simnet.errors import DataNotPublic, InvalidCookies, BadRequest as SimnetBadRequest from simnet.errors import DataNotPublic, InvalidCookies, BadRequest as SimnetBadRequest
from simnet.models.lab.record import Account from simnet.models.lab.record import Account
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, TelegramObject, Update from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, TelegramObject, Update
@ -226,7 +226,7 @@ class AccountCookiesPlugin(Plugin.Conversation):
logger.error("用户 %s[%s] region 异常", user.full_name, user.id) logger.error("用户 %s[%s] region 异常", user.full_name, user.id)
await message.reply_text("数据错误", reply_markup=ReplyKeyboardRemove()) await message.reply_text("数据错误", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END return ConversationHandler.END
async with GenshinClient(cookies=cookies.to_dict(), region=region) as client: async with ZZZClient(cookies=cookies.to_dict(), region=region) as client:
check_cookie = cookies.check() check_cookie = cookies.check()
if cookies.login_ticket is not None: if cookies.login_ticket is not None:
try: try:
@ -301,7 +301,7 @@ class AccountCookiesPlugin(Plugin.Conversation):
logger.success("获取用户 %s[%s] account_id[%s] 成功", user.full_name, user.id, account_id) logger.success("获取用户 %s[%s] account_id[%s] 成功", user.full_name, user.id, account_id)
else: else:
account_cookies_plugin_data.account_id = client.account_id account_cookies_plugin_data.account_id = client.account_id
genshin_accounts = await client.get_genshin_accounts() genshin_accounts = await client.get_zzz_accounts()
except DataNotPublic: except DataNotPublic:
logger.info("用户 %s[%s] 账号疑似被注销", user.full_name, user.id) logger.info("用户 %s[%s] 账号疑似被注销", user.full_name, user.id)
await message.reply_text("账号疑似被注销,请检查账号状态", reply_markup=ReplyKeyboardRemove()) await message.reply_text("账号疑似被注销,请检查账号状态", reply_markup=ReplyKeyboardRemove())
@ -342,7 +342,7 @@ class AccountCookiesPlugin(Plugin.Conversation):
level = temp.level level = temp.level
genshin_account = temp genshin_account = temp
if genshin_account is None: if genshin_account is None:
await message.reply_text("未找到原神账号,请确认账号信息无误。") await message.reply_text("未找到绝区零账号,请确认账号信息无误。")
return ConversationHandler.END return ConversationHandler.END
account_cookies_plugin_data.genshin_account = genshin_account account_cookies_plugin_data.genshin_account = genshin_account
player_info = await self.players_service.get( player_info = await self.players_service.get(
@ -408,12 +408,13 @@ class AccountCookiesPlugin(Plugin.Conversation):
region=region, region=region,
is_chosen=True, # todo 多账号 is_chosen=True, # todo 多账号
) )
await self.update_player_info(player_model, genshin_account.nickname)
await self.players_service.add(player_model) await self.players_service.add(player_model)
player = player_model
await self.update_player_info(player, genshin_account.nickname)
async def update_player_info(self, player: Player, nickname: str): async def update_player_info(self, player: Player, nickname: str):
player_info = await self.player_info_service.get(player) player_info = await self.player_info_service.get(player)
if player_info is None: if player_info is None or player_info.create_time is None:
player_info = PlayerInfoSQLModel( player_info = PlayerInfoSQLModel(
user_id=player.user_id, user_id=player.user_id,
player_id=player.player_id, player_id=player.player_id,

View File

@ -2,7 +2,7 @@ import html
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from typing import Tuple, TYPE_CHECKING from typing import Tuple, TYPE_CHECKING
from simnet import Region, GenshinClient from simnet import Region, ZZZClient
from telegram import InlineKeyboardMarkup, InlineKeyboardButton from telegram import InlineKeyboardMarkup, InlineKeyboardButton
from telegram.ext import filters from telegram.ext import filters
from core.config import config from core.config import config
@ -223,7 +223,7 @@ class PlayersManagesPlugin(Plugin):
if cookies.stoken is not None: if cookies.stoken is not None:
try: try:
region = Region.CHINESE if player.region.value == 1 else Region.OVERSEAS region = Region.CHINESE if player.region.value == 1 else Region.OVERSEAS
async with GenshinClient(cookies=cookies.to_dict(), region=region) as client: async with ZZZClient(cookies=cookies.to_dict(), region=region) as client:
cookies.cookie_token = await client.get_cookie_token_by_stoken() cookies.cookie_token = await client.get_cookie_token_by_stoken()
logger.success("用户 %s[%s] 刷新 cookie_token 成功", user.full_name, user.id) logger.success("用户 %s[%s] 刷新 cookie_token 成功", user.full_name, user.id)
cookies.ltoken = await client.get_ltoken_by_stoken() cookies.ltoken = await client.get_ltoken_by_stoken()

View File

@ -71,7 +71,7 @@ class GetChat(Plugin):
if player_info.region == RegionEnum.HYPERION: if player_info.region == RegionEnum.HYPERION:
text += "米游社绑定:" text += "米游社绑定:"
else: else:
text += "原神绑定:" text += "绝区零绑定:"
cookies_info = await self.cookies_service.get(chat.id, player_info.account_id, player_info.region) cookies_info = await self.cookies_service.get(chat.id, player_info.account_id, player_info.region)
if cookies_info is None: if cookies_info is None:
temp = "UID 绑定" temp = "UID 绑定"

View File

@ -68,8 +68,8 @@ class Post(Plugin.Conversation):
MENU_KEYBOARD = ReplyKeyboardMarkup([["推送频道", "添加TAG"], ["编辑文字", "删除图片"], ["退出"]], True, True) MENU_KEYBOARD = ReplyKeyboardMarkup([["推送频道", "添加TAG"], ["编辑文字", "删除图片"], ["退出"]], True, True)
def __init__(self): def __init__(self):
self.gids = 2 self.gids = 8
self.short_name = "ys" self.short_name = "zzz"
self.last_post_id_list: Dict[PostTypeEnum, List[int]] = {PostTypeEnum.CN: [], PostTypeEnum.OS: []} self.last_post_id_list: Dict[PostTypeEnum, List[int]] = {PostTypeEnum.CN: [], PostTypeEnum.OS: []}
self.ffmpeg_enable = False self.ffmpeg_enable = False
self.cache_dir = os.path.join(os.getcwd(), "cache") self.cache_dir = os.path.join(os.getcwd(), "cache")
@ -358,6 +358,8 @@ class Post(Plugin.Conversation):
if too_long or len(post_text) >= MessageLimit.CAPTION_LENGTH: if too_long or len(post_text) >= MessageLimit.CAPTION_LENGTH:
post_text = post_text[: MessageLimit.CAPTION_LENGTH] post_text = post_text[: MessageLimit.CAPTION_LENGTH]
await message.reply_text(f"警告!图片字符描述已经超过 {MessageLimit.CAPTION_LENGTH} 个字,已经切割") await message.reply_text(f"警告!图片字符描述已经超过 {MessageLimit.CAPTION_LENGTH} 个字,已经切割")
if post_info.video_urls:
await message.reply_text("检测到视频,需要单独下载,视频链接:" + "\n".join(post_info.video_urls))
try: try:
if len(post_images) > 1: if len(post_images) > 1:
media = [self.input_media(img_info) for img_info in post_images if not img_info.is_error] media = [self.input_media(img_info) for img_info in post_images if not img_info.is_error]

View File

@ -1,230 +0,0 @@
import re
from typing import List
from redis import DataError, ResponseError
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import CallbackContext, ConversationHandler, filters
from telegram.helpers import escape_markdown
from core.plugin import Plugin, conversation, handler
from core.services.quiz.models import Answer, Question
from core.services.quiz.services import QuizService
from utils.log import logger
(
CHECK_COMMAND,
VIEW_COMMAND,
CHECK_QUESTION,
GET_NEW_QUESTION,
GET_NEW_CORRECT_ANSWER,
GET_NEW_WRONG_ANSWER,
QUESTION_EDIT,
SAVE_QUESTION,
) = range(10300, 10308)
class QuizCommandData:
question_id: int = -1
new_question: str = ""
new_correct_answer: str = ""
new_wrong_answer: List[str] = []
status: int = 0
class SetQuizPlugin(Plugin.Conversation):
"""派蒙的十万个为什么问题修改/添加/删除"""
def __init__(self, quiz_service: QuizService = None):
self.quiz_service = quiz_service
self.time_out = 120
@conversation.entry_point
@handler.command(command="set_quiz", filters=filters.ChatType.PRIVATE, block=False, admin=True)
async def command_start(self, update: Update, context: CallbackContext) -> int:
user = update.effective_user
message = update.effective_message
logger.info("用户 %s[%s] set_quiz命令请求", user.full_name, user.id)
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
if quiz_command_data is None:
quiz_command_data = QuizCommandData()
context.chat_data["quiz_command_data"] = quiz_command_data
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_COMMAND
async def view_command(self, update: Update, _: CallbackContext) -> int:
_ = self
keyboard = [[InlineKeyboardButton(text="选择问题", switch_inline_query_current_chat="查看问题 ")]]
await update.message.reply_text("请回复你要查看的问题", reply_markup=InlineKeyboardMarkup(keyboard))
return CHECK_COMMAND
@conversation.state(state=CHECK_QUESTION)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def check_question(self, update: Update, _: CallbackContext) -> int:
reply_keyboard = [["删除问题"], ["退出"]]
await update.message.reply_text("请选择你的操作", reply_markup=ReplyKeyboardMarkup(reply_keyboard))
return CHECK_COMMAND
@conversation.state(state=CHECK_COMMAND)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def check_command(self, update: Update, context: CallbackContext) -> int:
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
if update.message.text == "退出":
await update.message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
if update.message.text == "查看问题":
return await self.view_command(update, context)
if update.message.text == "添加问题":
return await self.add_question(update, context)
if update.message.text == "删除问题":
return await self.delete_question(update, context)
# elif update.message.text == "修改问题":
# return await self.edit_question(update, context)
if update.message.text == "重载问题":
return await self.refresh_question(update, context)
result = re.findall(r"问题ID (\d+)", update.message.text)
if len(result) == 1:
try:
question_id = int(result[0])
except ValueError:
await update.message.reply_text("获取问题ID失败")
return ConversationHandler.END
quiz_command_data.question_id = question_id
await update.message.reply_text("获取问题ID成功")
return await self.check_question(update, context)
await update.message.reply_text("命令错误", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
async def refresh_question(self, update: Update, _: CallbackContext) -> int:
try:
await self.quiz_service.refresh_quiz()
except DataError:
await update.message.reply_text("Redis数据错误重载失败", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
except ResponseError as exc:
logger.error("重载问题失败", exc_info=exc)
await update.message.reply_text(
"重载问题失败异常抛出Redis请求错误异常详情错误请看日记", reply_markup=ReplyKeyboardRemove()
)
return ConversationHandler.END
await update.message.reply_text("重载成功", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
async def add_question(self, update: Update, context: CallbackContext) -> int:
_ = self
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
quiz_command_data.new_wrong_answer = []
quiz_command_data.new_question = ""
quiz_command_data.new_correct_answer = ""
quiz_command_data.status = 1
await update.message.reply_text(
"请回复你要添加的问题,或发送 /cancel 取消操作", reply_markup=ReplyKeyboardRemove()
)
return GET_NEW_QUESTION
@conversation.state(state=GET_NEW_QUESTION)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def get_new_question(self, update: Update, context: CallbackContext) -> int:
message = update.effective_message
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
reply_text = f"问题:`{escape_markdown(update.message.text, version=2)}`\n" f"请填写正确答案:"
quiz_command_data.new_question = message.text
await update.message.reply_markdown_v2(reply_text)
return GET_NEW_CORRECT_ANSWER
@conversation.state(state=GET_NEW_CORRECT_ANSWER)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def get_new_correct_answer(self, update: Update, context: CallbackContext) -> int:
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
reply_text = f"正确答案:`{escape_markdown(update.message.text, version=2)}`\n" f"请填写错误答案:"
await update.message.reply_markdown_v2(reply_text)
quiz_command_data.new_correct_answer = update.message.text
return GET_NEW_WRONG_ANSWER
@conversation.state(state=GET_NEW_WRONG_ANSWER)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
@handler.command(command="finish_edit", block=False)
async def get_new_wrong_answer(self, update: Update, context: CallbackContext) -> int:
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
reply_text = (
f"错误答案:`{escape_markdown(update.message.text, version=2)}`\n"
f"可继续填写,并使用 {escape_markdown('/finish', version=2)} 结束。"
)
await update.message.reply_markdown_v2(reply_text)
quiz_command_data.new_wrong_answer.append(update.message.text)
return GET_NEW_WRONG_ANSWER
async def finish_edit(self, update: Update, context: CallbackContext):
_ = self
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
reply_text = (
f"问题:`{escape_markdown(quiz_command_data.new_question, version=2)}`\n"
f"正确答案:`{escape_markdown(quiz_command_data.new_correct_answer, version=2)}`\n"
f"错误答案:`{escape_markdown(' '.join(quiz_command_data.new_wrong_answer), version=2)}`"
)
await update.message.reply_markdown_v2(reply_text)
reply_keyboard = [["保存并重载配置", "抛弃修改并退出"]]
await update.message.reply_text(
"请核对问题,并选择下一步操作。", reply_markup=ReplyKeyboardMarkup(reply_keyboard)
)
return SAVE_QUESTION
@conversation.state(state=SAVE_QUESTION)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def save_question(self, update: Update, context: CallbackContext):
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
if update.message.text == "抛弃修改并退出":
await update.message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
if update.message.text == "保存并重载配置":
if quiz_command_data.status == 1:
answer = [
Answer(text=wrong_answer, is_correct=False) for wrong_answer in quiz_command_data.new_wrong_answer
]
answer.append(Answer(text=quiz_command_data.new_correct_answer, is_correct=True))
await self.quiz_service.save_quiz(Question(text=quiz_command_data.new_question))
await update.message.reply_text("保存成功", reply_markup=ReplyKeyboardRemove())
try:
await self.quiz_service.refresh_quiz()
except ResponseError as exc:
logger.error("重载问题失败", exc_info=exc)
await update.message.reply_text(
"重载问题失败异常抛出Redis请求错误异常详情错误请看日记", reply_markup=ReplyKeyboardRemove()
)
return ConversationHandler.END
await update.message.reply_text("重载配置成功", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
await update.message.reply_text("回复错误,请重新选择")
return SAVE_QUESTION
async def edit_question(self, update: Update, context: CallbackContext) -> int:
_ = self
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
quiz_command_data.new_wrong_answer = []
quiz_command_data.new_question = ""
quiz_command_data.new_correct_answer = ""
quiz_command_data.status = 2
await update.message.reply_text("请回复你要修改的问题", reply_markup=ReplyKeyboardRemove())
return GET_NEW_QUESTION
async def delete_question(self, update: Update, context: CallbackContext) -> int:
quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data")
# 再问题重载Redis 以免redis数据为空时出现奔溃
try:
await self.quiz_service.refresh_quiz()
question = await self.quiz_service.get_question(quiz_command_data.question_id)
# 因为外键的存在,先删除答案
for answer in question.answers:
await self.quiz_service.delete_question_by_id(answer.answer_id)
await self.quiz_service.delete_question_by_id(question.question_id)
await update.message.reply_text("删除问题成功", reply_markup=ReplyKeyboardRemove())
await self.quiz_service.refresh_quiz()
except ResponseError as exc:
logger.error("重载问题失败", exc_info=exc)
await update.message.reply_text(
"重载问题失败异常抛出Redis请求错误异常详情错误请看日记", reply_markup=ReplyKeyboardRemove()
)
return ConversationHandler.END
await update.message.reply_text("重载配置成功", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END

View File

@ -1,28 +0,0 @@
from telegram import Update
from telegram.ext import CallbackContext
from core.plugin import Plugin, handler
from metadata.scripts.honey import update_honey_metadata
from metadata.scripts.metadatas import update_metadata_from_ambr, update_metadata_from_github
from metadata.scripts.paimon_moe import update_paimon_moe_zh
from utils.log import logger
__all__ = ("MetadataPlugin",)
class MetadataPlugin(Plugin):
@handler.command("refresh_metadata", admin=True, block=False)
async def refresh(self, update: Update, _: CallbackContext) -> None:
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 刷新[bold]metadata[/]缓存命令", user.full_name, user.id, extra={"markup": True})
msg = await message.reply_text("正在刷新元数据,请耐心等待...")
logger.info("正在从 github 上获取元数据")
await update_metadata_from_github()
await update_paimon_moe_zh()
logger.info("正在从 ambr 上获取元数据")
await update_metadata_from_ambr()
logger.info("正在从 honey 上获取元数据")
await update_honey_metadata()
await msg.edit_text("正在刷新元数据,请耐心等待...\n完成!")

View File

@ -21,67 +21,31 @@ class SetCommandPlugin(Plugin):
user_command = [ user_command = [
BotCommand("cancel", "取消操作(解决一切玄学问题)"), BotCommand("cancel", "取消操作(解决一切玄学问题)"),
BotCommand("help_raw", "查看文本帮助"), BotCommand("help_raw", "查看文本帮助"),
# gacha_log 相关
BotCommand("wish_log", "查看抽卡记录"),
BotCommand("wish_log_import", "导入抽卡记录"),
BotCommand("wish_log_export", "导出抽卡记录"),
BotCommand("wish_log_delete", "删除抽卡记录"),
BotCommand("pay_log", "查看充值记录"),
BotCommand("pay_log_import", "导入充值记录"),
BotCommand("pay_log_export", "导出充值记录"),
BotCommand("pay_log_delete", "删除充值记录"),
# Cookie 查询类 # Cookie 查询类
BotCommand("sign", "米游社原神每日签到"), BotCommand("sign", "米游社绝区零每日签到"),
BotCommand("dailynote_tasks", "自动便笺提醒"), BotCommand("dailynote_tasks", "自动便笺提醒"),
# 其他 # 其他
BotCommand("hilichurls", "丘丘语字典"),
BotCommand("birthday", "查询角色生日"),
BotCommand("setuid", "添加/重设UID"), BotCommand("setuid", "添加/重设UID"),
BotCommand("setcookie", "添加/重设Cookie"), BotCommand("setcookie", "添加/重设Cookie"),
BotCommand("setdevice", "添加/重设设备"),
BotCommand("player", "管理用户绑定玩家"), BotCommand("player", "管理用户绑定玩家"),
BotCommand("verify", "手动验证"), BotCommand("verify", "手动验证"),
BotCommand("redeem", "(国际服)兑换 Key"),
] ]
group_command = [ group_command = [
# 通用 # 通用
BotCommand("help", "帮助"), BotCommand("help", "帮助"),
BotCommand("quiz", f"{config.notice.bot_name}的十万个为什么"),
BotCommand("wish", " 非洲人模拟器(抽卡模拟器)"),
BotCommand("set_wish", "抽卡模拟器定轨"),
BotCommand("calendar", "活动日历"),
# Wiki 类
BotCommand("weapon", "查询武器"),
BotCommand("strategy", "查询角色攻略"),
BotCommand("material", "角色培养素材查询"),
# UID 查询类 # UID 查询类
BotCommand("stats", "玩家统计查询"), BotCommand("stats", "玩家统计查询"),
BotCommand("player_card", "查询角色卡片"),
BotCommand("avatar_board", "角色排名"),
BotCommand("gcsim", "组队伤害计算"),
# Cookie 查询类 # Cookie 查询类
BotCommand("dailynote", "查询实时便笺"), BotCommand("dailynote", "查询实时便笺"),
BotCommand("ledger", "查询当月旅行札记"),
BotCommand("ledger_history", "查询旅行札记历史记录"),
BotCommand("abyss", "查询深渊战绩"),
BotCommand("abyss_team", "查询深渊推荐配队"),
BotCommand("abyss_history", "查询深渊历史战绩"),
BotCommand("avatars", "查询角色练度"),
BotCommand("reg_time", "账号注册时间"),
BotCommand("daily_material", "今日素材表"),
BotCommand("cookies_import", "从其他 BOT 导入账号信息"), BotCommand("cookies_import", "从其他 BOT 导入账号信息"),
BotCommand("cookies_export", "导出账号信息给其他 BOT"), BotCommand("cookies_export", "导出账号信息给其他 BOT"),
] ]
admin_command = [ admin_command = [
BotCommand("add_admin", "添加管理员"), BotCommand("add_admin", "添加管理员"),
BotCommand("del_admin", "删除管理员"), BotCommand("del_admin", "删除管理员"),
BotCommand("refresh_metadata", "刷新元数据"),
BotCommand("refresh_wiki", "刷新Wiki缓存"),
BotCommand("refresh_map", "刷新地图数据"),
BotCommand("save_entry", "保存条目数据"), BotCommand("save_entry", "保存条目数据"),
BotCommand("remove_all_entry", "删除全部条目数据"), BotCommand("remove_all_entry", "删除全部条目数据"),
BotCommand("sign_all", "全部账号重新签到"), BotCommand("sign_all", "全部账号重新签到"),
BotCommand("refresh_all_history", "全部账号刷新历史记录"),
BotCommand("send_log", "发送日志"), BotCommand("send_log", "发送日志"),
BotCommand("update", "更新"), BotCommand("update", "更新"),
BotCommand("set_command", "重设命令"), BotCommand("set_command", "重设命令"),

View File

@ -1,19 +0,0 @@
from telegram import Update
from telegram.ext import CallbackContext
from core.plugin import Plugin, handler
from core.services.wiki.services import WikiService
class WikiPlugin(Plugin):
"""有关WIKI操作"""
def __init__(self, wiki_service: WikiService):
self.wiki_service = wiki_service
@handler.command("refresh_wiki", block=False, admin=True)
async def refresh_wiki(self, update: Update, _: CallbackContext):
message = update.effective_message
await message.reply_text("正在刷新Wiki缓存请稍等")
await self.wiki_service.refresh_wiki()
await message.reply_text("刷新Wiki缓存成功")

View File

@ -1,4 +1,3 @@
import asyncio
from typing import Awaitable, Dict, List, cast, Tuple from typing import Awaitable, Dict, List, cast, Tuple
from uuid import uuid4 from uuid import uuid4
@ -17,12 +16,10 @@ from telegram.constants import ParseMode
from telegram.error import BadRequest from telegram.error import BadRequest
from telegram.ext import CallbackContext, ContextTypes from telegram.ext import CallbackContext, ContextTypes
from core.dependence.assets import AssetsCouldNotFound, AssetsService
from core.plugin import Plugin, handler from core.plugin import Plugin, handler
from core.services.cookies import CookiesService from core.services.cookies import CookiesService
from core.services.players import PlayersService from core.services.players import PlayersService
from core.services.search.services import SearchServices from core.services.search.services import SearchServices
from core.services.wiki.services import WikiService
from gram_core.config import config from gram_core.config import config
from gram_core.plugin.methods.inline_use_data import IInlineUseData from gram_core.plugin.methods.inline_use_data import IInlineUseData
from utils.log import logger from utils.log import logger
@ -33,14 +30,10 @@ class Inline(Plugin):
def __init__( def __init__(
self, self,
wiki_service: WikiService,
assets_service: AssetsService,
search_service: SearchServices, search_service: SearchServices,
cookies_service: CookiesService, cookies_service: CookiesService,
players_service: PlayersService, players_service: PlayersService,
): ):
self.assets_service = assets_service
self.wiki_service = wiki_service
self.weapons_list: List[Dict[str, str]] = [] self.weapons_list: List[Dict[str, str]] = []
self.characters_list: List[Dict[str, str]] = [] self.characters_list: List[Dict[str, str]] = []
self.refresh_task: List[Awaitable] = [] self.refresh_task: List[Awaitable] = []
@ -51,41 +44,6 @@ class Inline(Plugin):
self.inline_use_data_map: Dict[str, IInlineUseData] = {} self.inline_use_data_map: Dict[str, IInlineUseData] = {}
self.img_url = "https://i.dawnlab.me/b1bdf9cc3061d254f038e557557694bc.jpg" self.img_url = "https://i.dawnlab.me/b1bdf9cc3061d254f038e557557694bc.jpg"
async def initialize(self):
# todo: 整合进 wiki 或者单独模块 从Redis中读取
async def task_weapons():
logger.info("Inline 模块正在获取武器列表")
weapons_list = await self.wiki_service.get_weapons_name_list()
for weapons_name in weapons_list:
try:
icon = await self.assets_service.weapon(weapons_name).get_link("icon")
except AssetsCouldNotFound:
continue
except Exception as exc:
logger.error("获取武器信息失败 %s", str(exc))
continue
data = {"name": weapons_name, "icon": icon}
self.weapons_list.append(data)
logger.success("Inline 模块获取武器列表成功")
async def task_characters():
logger.info("Inline 模块正在获取角色列表")
characters_list = await self.wiki_service.get_characters_name_list()
for character_name in characters_list:
try:
icon = await self.assets_service.avatar(character_name).get_link("icon")
except AssetsCouldNotFound:
continue
except Exception as exc:
logger.error("获取角色信息失败 %s", str(exc))
continue
data = {"name": character_name, "icon": icon}
self.characters_list.append(data)
logger.success("Inline 模块获取角色列表成功")
self.refresh_task.append(asyncio.create_task(task_weapons()))
self.refresh_task.append(asyncio.create_task(task_characters()))
async def init_inline_use_data(self): async def init_inline_use_data(self):
if self.inline_use_data: if self.inline_use_data:
return return
@ -260,19 +218,6 @@ class Inline(Plugin):
), ),
) )
) )
elif args[0] == "查看角色培养素材列表并查询":
characters_list = await self.wiki_service.get_characters_name_list()
for role_name in characters_list:
results_list.append(
InlineQueryResultArticle(
id=str(uuid4()),
title=role_name,
description=f"查看角色培养素材列表并查询 {role_name}",
input_message_content=InputTextMessageContent(
f"角色培养素材查询{role_name}", parse_mode=ParseMode.MARKDOWN_V2
),
)
)
else: else:
simple_search_results = await self.search_service.search(args[0]) simple_search_results = await self.search_service.search(args[0])
if simple_search_results: if simple_search_results:

View File

@ -1,627 +0,0 @@
"""深渊数据查询"""
import asyncio
import math
import re
from datetime import datetime
from functools import lru_cache, partial
from typing import Any, Coroutine, List, Optional, Tuple, Union, Dict
from arkowrapper import ArkoWrapper
from pytz import timezone
from simnet import GenshinClient
from simnet.models.genshin.chronicle.abyss import SpiralAbyss
from telegram import Message, Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction, ParseMode
from telegram.ext import CallbackContext, filters, ContextTypes
from core.dependence.assets import AssetsService
from core.plugin import Plugin, handler
from core.services.cookies.error import TooManyRequestPublicCookies
from core.services.history_data.models import HistoryDataAbyss
from core.services.history_data.services import HistoryDataAbyssServices
from core.services.template.models import RenderGroupResult, RenderResult
from core.services.template.services import TemplateService
from gram_core.config import config
from gram_core.dependence.redisdb import RedisDB
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from plugins.tools.genshin import GenshinHelper
from utils.enkanetwork import RedisCache
from utils.log import logger
from utils.uid import mask_number
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
TZ = timezone("Asia/Shanghai")
get_args_pattern = re.compile(r"\d+")
@lru_cache
def get_args(text: str) -> Tuple[int, bool, bool]:
total = "all" in text or "总览" in text
prev = "pre" in text or "上期" in text
floor = 0
if not total:
m = get_args_pattern.search(text)
if m is not None:
floor = int(m.group(0))
return floor, total, prev
class AbyssUnlocked(Exception):
"""根本没动"""
class NoMostKills(Exception):
"""挑战了但是数据没刷新"""
class FloorNotFoundError(Exception):
"""只有数据统计,层数统计未出"""
class AbyssNotFoundError(Exception):
"""如果查询别人,是无法找到队伍详细,只有数据统计"""
class AbyssPlugin(Plugin):
"""深渊数据查询"""
def __init__(
self,
template: TemplateService,
helper: GenshinHelper,
assets_service: AssetsService,
history_data_abyss: HistoryDataAbyssServices,
redis: RedisDB,
):
self.template_service = template
self.helper = helper
self.assets_service = assets_service
self.history_data_abyss = history_data_abyss
self.cache = RedisCache(redis.client, key="plugin:abyss:history")
@handler.command("abyss", block=False)
@handler.message(filters.Regex(r"^深渊数据"), block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None: # skipcq: PY-R1000 #
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
args = self.get_args(context)
message = update.effective_message
# 若查询帮助
if (message.text.startswith("/") and "help" in message.text) or "帮助" in message.text:
await message.reply_text(
"<b>深渊挑战数据</b>功能使用帮助(中括号表示可选参数)\n\n"
"指令格式:\n<code>/abyss + [层数/all] + [pre]</code>\n<code>pre</code>表示上期)\n\n"
"文本格式:\n<code>深渊数据 + 查询/总览 + [上期] + [层数]</code> \n\n"
"例如以下指令都正确:\n"
"<code>/abyss</code>\n<code>/abyss 12 pre</code>\n<code>/abyss all pre</code>\n"
"<code>深渊数据查询</code>\n<code>深渊数据查询上期第12层</code>\n<code>深渊数据总览上期</code>",
parse_mode=ParseMode.HTML,
)
self.log_user(update, logger.info, "查询[bold]深渊挑战数据[/bold]帮助", extra={"markup": True})
return
# 解析参数
floor, total, previous = get_args(" ".join([i for i in args if not i.startswith("@")]))
if floor > 12 or floor < 0:
reply_msg = await message.reply_text("深渊层数输入错误,请重新输入。支持的参数为: 1-12 或 all")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_msg)
self.add_delete_message_job(message)
return
if 0 < floor < 9:
previous = False
self.log_user(
update,
logger.info,
"[bold]深渊挑战数据[/bold]请求: floor=%s total=%s previous=%s",
floor,
total,
previous,
extra={"markup": True},
)
await message.reply_chat_action(ChatAction.TYPING)
reply_text: Optional[Message] = None
if total:
reply_text = await message.reply_text(f"{config.notice.bot_name}需要时间整理深渊数据,还请耐心等待哦~")
try:
async with self.helper.genshin_or_public(user_id, uid=uid, offset=offset) as client:
if not client.public:
await client.get_record_cards()
abyss_data, avatar_data = await self.get_rendered_pic_data(client, client.player_id, previous)
images = await self.get_rendered_pic(abyss_data, avatar_data, client.player_id, floor, total)
except AbyssUnlocked: # 若深渊未解锁
await message.reply_text("还未解锁深渊哦~")
return
except NoMostKills: # 若深渊还未挑战
await message.reply_text("还没有挑战本次深渊呢,咕咕咕~")
return
except FloorNotFoundError:
await message.reply_text("深渊详细数据未找到,咕咕咕~")
return
except AbyssNotFoundError:
await message.reply_text("无法查询玩家挑战队伍详情,只能查询统计详情哦~")
return
except TooManyRequestPublicCookies:
reply_message = await message.reply_text("查询次数太多,请您稍后重试")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_message)
self.add_delete_message_job(message)
return
finally:
if reply_text is not None:
await reply_text.delete()
if images is None:
await message.reply_text(f"还没有第 {floor} 层的挑战数据")
return
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
for group in ArkoWrapper(images).group(10): # 每 10 张图片分一个组
await RenderGroupResult(results=group).reply_media_group(message, write_timeout=60)
self.log_user(update, logger.info, "[bold]深渊挑战数据[/bold]: 成功发送图片", extra={"markup": True})
async def get_rendered_pic_data(
self, client: GenshinClient, uid: int, previous: bool
) -> Tuple["SpiralAbyss", Dict[int, int]]:
abyss_data = await client.get_genshin_spiral_abyss(uid, previous=previous, lang="zh-cn")
avatar_data = {}
if not client.public: # noqa
avatars = await client.get_genshin_characters(uid, lang="zh-cn")
avatar_data = {i.id: i.constellation for i in avatars}
if abyss_data.unlocked and abyss_data.ranks.most_kills:
await self.save_abyss_data(self.history_data_abyss, uid, abyss_data, avatar_data)
return abyss_data, avatar_data
async def get_rendered_pic( # skipcq: PY-R1000 #
self, abyss_data: "SpiralAbyss", avatar_data: Dict[int, int], uid: int, floor: int, total: bool
) -> Union[Tuple[Any], List[RenderResult], None]:
"""
获取渲染后的图片
Args:
abyss_data (SpiralAbyss): 深渊数据
avatar_data (Dict[int, int]): 角色数据
uid (int): 需要查询的 uid
floor (int): 层数
total (bool): 是否为总览
Returns:
bytes格式的图片
"""
def json_encoder(value):
if isinstance(value, datetime):
return value.astimezone(TZ).strftime("%Y-%m-%d %H:%M:%S")
return value
if not abyss_data.unlocked:
raise AbyssUnlocked
if not abyss_data.ranks.most_kills:
raise NoMostKills
if (total or (floor > 0)) and len(abyss_data.floors) == 0:
raise FloorNotFoundError
if (total or (floor > 0)) and len(abyss_data.floors[0].chambers[0].battles) == 0:
raise AbyssNotFoundError
start_time = abyss_data.start_time.astimezone(TZ)
time = start_time.strftime("%Y年%m月") + ("" if start_time.day <= 15 else "")
stars = [i.stars for i in filter(lambda x: x.floor > 8, abyss_data.floors)]
total_stars = f"{sum(stars)} ({'-'.join(map(str, stars))})"
render_data = {}
result = abyss_data.json(encoder=json_encoder)
render_data["time"] = time
render_data["stars"] = total_stars
render_data["uid"] = mask_number(uid)
render_data["floor_colors"] = {
1: "#374952",
2: "#374952",
3: "#55464B",
4: "#55464B",
5: "#55464B",
6: "#1D2A5D",
7: "#1D2A5D",
8: "#1D2A5D",
9: "#292B58",
10: "#382024",
11: "#252550",
12: "#1D2A4A",
}
if total:
render_data["avatar_data"] = avatar_data
data = jsonlib.loads(result)
render_data["data"] = data
render_inputs: List[Tuple[int, Coroutine[Any, Any, RenderResult]]] = []
def overview_task():
return -1, self.template_service.render(
"genshin/abyss/overview.jinja2", render_data, viewport={"width": 750, "height": 580}
)
def floor_task(floor_index: int):
floor_d = data["floors"][floor_index]
return (
floor_d["floor"],
self.template_service.render(
"genshin/abyss/floor.jinja2",
{
**render_data,
"floor": floor_d,
"total_stars": f"{floor_d['stars']}/{floor_d['max_stars']}",
},
viewport={"width": 690, "height": 500},
full_page=True,
ttl=15 * 24 * 60 * 60,
),
)
render_inputs.append(overview_task())
for i, f in enumerate(data["floors"]):
if f["floor"] >= 9:
render_inputs.append(floor_task(i))
render_group_inputs = list(map(lambda x: x[1], sorted(render_inputs, key=lambda x: x[0])))
return await asyncio.gather(*render_group_inputs)
if floor < 1:
render_data["data"] = jsonlib.loads(result)
return [
await self.template_service.render(
"genshin/abyss/overview.jinja2", render_data, viewport={"width": 750, "height": 580}
)
]
num_dic = {
"0": "",
"1": "",
"2": "",
"3": "",
"4": "",
"5": "",
"6": "",
"7": "",
"8": "",
"9": "",
}
if num := num_dic.get(str(floor)):
render_data["floor-num"] = num
else:
render_data["floor-num"] = f"{num_dic.get(str(floor % 10))}"
floors = jsonlib.loads(result)["floors"]
if not (floor_data := list(filter(lambda x: x["floor"] == floor, floors))):
return None
render_data["avatar_data"] = avatar_data
render_data["floor"] = floor_data[0]
render_data["total_stars"] = f"{floor_data[0]['stars']}/{floor_data[0]['max_stars']}"
return [
await self.template_service.render(
"genshin/abyss/floor.jinja2", render_data, viewport={"width": 690, "height": 500}
)
]
@staticmethod
async def save_abyss_data(
history_data_abyss: "HistoryDataAbyssServices",
uid: int,
abyss_data: "SpiralAbyss",
character_data: Dict[int, int],
) -> bool:
model = history_data_abyss.create(uid, abyss_data, character_data)
old_data = await history_data_abyss.get_by_user_id_data_id(uid, model.data_id)
exists = history_data_abyss.exists_data(model, old_data)
if not exists:
await history_data_abyss.add(model)
return True
return False
async def get_abyss_data(self, uid: int):
return await self.history_data_abyss.get_by_user_id(uid)
@staticmethod
def get_season_data_name(data: "HistoryDataAbyss"):
start_time = data.abyss_data.start_time.astimezone(TZ)
time = start_time.strftime("%Y.%m ")[2:] + ("" if start_time.day <= 15 else "")
honor = ""
if data.abyss_data.total_stars == 36:
if data.abyss_data.total_battles == 12:
honor = "👑"
last_battles = data.abyss_data.floors[-1].chambers[-1].battles
num_of_characters = max(
len(last_battles[0].characters),
len(last_battles[1].characters),
)
if num_of_characters == 2:
honor = "双通"
elif num_of_characters == 1:
honor = "单通"
return f"{time} {data.abyss_data.total_stars}{honor}"
async def get_session_button_data(self, user_id: int, uid: int, force: bool = False):
redis = await self.cache.get(str(uid))
if redis and not force:
return redis["buttons"]
data = await self.get_abyss_data(uid)
data.sort(key=lambda x: x.id, reverse=True)
abyss_data = [HistoryDataAbyss.from_data(i) for i in data]
buttons = [
{
"name": AbyssPlugin.get_season_data_name(abyss_data[idx]),
"value": f"get_abyss_history|{user_id}|{uid}|{value.id}",
}
for idx, value in enumerate(data)
]
await self.cache.set(str(uid), {"buttons": buttons})
return buttons
async def gen_season_button(
self,
user_id: int,
uid: int,
page: int = 1,
) -> List[List[InlineKeyboardButton]]:
"""生成按钮"""
data = await self.get_session_button_data(user_id, uid)
if not data:
return []
buttons = [
InlineKeyboardButton(
value["name"],
callback_data=value["value"],
)
for value in data
]
all_buttons = [buttons[i : i + 3] for i in range(0, len(buttons), 3)]
send_buttons = all_buttons[(page - 1) * 5 : page * 5]
last_page = page - 1 if page > 1 else 0
all_page = math.ceil(len(all_buttons) / 5)
next_page = page + 1 if page < all_page and all_page > 1 else 0
last_button = []
if last_page:
last_button.append(
InlineKeyboardButton(
"<< 上一页",
callback_data=f"get_abyss_history|{user_id}|{uid}|p_{last_page}",
)
)
if last_page or next_page:
last_button.append(
InlineKeyboardButton(
f"{page}/{all_page}",
callback_data=f"get_abyss_history|{user_id}|{uid}|empty_data",
)
)
if next_page:
last_button.append(
InlineKeyboardButton(
"下一页 >>",
callback_data=f"get_abyss_history|{user_id}|{uid}|p_{next_page}",
)
)
if last_button:
send_buttons.append(last_button)
return send_buttons
@staticmethod
async def gen_floor_button(
data_id: int,
abyss_data: "HistoryDataAbyss",
user_id: int,
uid: int,
) -> List[List[InlineKeyboardButton]]:
floors = [i.floor for i in abyss_data.abyss_data.floors if i.floor]
floors.sort()
buttons = [
InlineKeyboardButton(
f"{i}",
callback_data=f"get_abyss_history|{user_id}|{uid}|{data_id}|{i}",
)
for i in floors
]
send_buttons = [buttons[i : i + 4] for i in range(0, len(buttons), 4)]
all_buttons = [
InlineKeyboardButton(
"<< 返回",
callback_data=f"get_abyss_history|{user_id}|{uid}|p_1",
),
InlineKeyboardButton(
"总览",
callback_data=f"get_abyss_history|{user_id}|{uid}|{data_id}|total",
),
InlineKeyboardButton(
"所有",
callback_data=f"get_abyss_history|{user_id}|{uid}|{data_id}|all",
),
]
send_buttons.append(all_buttons)
return send_buttons
@handler.command("abyss_history", block=False)
@handler.message(filters.Regex(r"^深渊历史数据"), block=False)
async def abyss_history_command_start(self, update: Update, _: CallbackContext) -> None:
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
self.log_user(update, logger.info, "查询深渊历史数据")
async with self.helper.genshin_or_public(user_id, uid=uid, offset=offset) as client:
await self.get_session_button_data(user_id, client.player_id, force=True)
buttons = await self.gen_season_button(user_id, client.player_id)
if not buttons:
await message.reply_text("还没有深渊历史数据哦~")
return
await message.reply_text("请选择要查询的深渊历史数据", reply_markup=InlineKeyboardMarkup(buttons))
async def get_abyss_history_page(self, update: "Update", user_id: int, uid: int, result: str):
"""翻页处理"""
callback_query = update.callback_query
self.log_user(update, logger.info, "切换深渊历史数据页 page[%s]", result)
page = int(result.split("_")[1])
async with self.helper.genshin_or_public(user_id, uid=uid) as client:
buttons = await self.gen_season_button(user_id, client.player_id, page)
if not buttons:
await callback_query.answer("还没有深渊历史数据哦~", show_alert=True)
await callback_query.edit_message_text("还没有深渊历史数据哦~")
return
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
await callback_query.answer(f"已切换到第 {page}", show_alert=False)
async def get_abyss_history_season(self, update: "Update", data_id: int):
"""进入选择层数"""
callback_query = update.callback_query
user = callback_query.from_user
self.log_user(update, logger.info, "切换深渊历史数据到层数页 data_id[%s]", data_id)
data = await self.history_data_abyss.get_by_id(data_id)
if not data:
await callback_query.answer("数据不存在,请尝试重新发送命令~", show_alert=True)
await callback_query.edit_message_text("数据不存在,请尝试重新发送命令~")
return
abyss_data = HistoryDataAbyss.from_data(data)
buttons = await self.gen_floor_button(data_id, abyss_data, user.id, data.user_id)
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
await callback_query.answer("已切换到层数页", show_alert=False)
async def get_abyss_history_floor(self, update: "Update", data_id: int, detail: str):
"""渲染层数数据"""
callback_query = update.callback_query
message = callback_query.message
reply = None
if message.reply_to_message:
reply = message.reply_to_message
floor = 0
total = False
if detail == "total":
floor = 0
elif detail == "all":
total = True
else:
floor = int(detail)
data = await self.history_data_abyss.get_by_id(data_id)
if not data:
await callback_query.answer("数据不存在,请尝试重新发送命令", show_alert=True)
await callback_query.edit_message_text("数据不存在,请尝试重新发送命令~")
return
abyss_data = HistoryDataAbyss.from_data(data)
images = await self.get_rendered_pic(
abyss_data.abyss_data, abyss_data.character_data, data.user_id, floor, total
)
if images is None:
await callback_query.answer(f"还没有第 {floor} 层的挑战数据", show_alert=True)
return
await callback_query.answer("正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
for group in ArkoWrapper(images).group(10): # 每 10 张图片分一个组
await RenderGroupResult(results=group).reply_media_group(reply or message, write_timeout=60)
self.log_user(update, logger.info, "[bold]深渊挑战数据[/bold]: 成功发送图片", extra={"markup": True})
self.add_delete_message_job(message, delay=1)
@handler.callback_query(pattern=r"^get_abyss_history\|", block=False)
async def get_abyss_history(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
async def get_abyss_history_callback(
callback_query_data: str,
) -> Tuple[str, str, int, int]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_uid = int(_data[2])
_result = _data[3]
_detail = _data[4] if len(_data) > 4 else None
logger.debug(
"callback_query_data函数返回 detail[%s] result[%s] user_id[%s] uid[%s]",
_detail,
_result,
_user_id,
_uid,
)
return _detail, _result, _user_id, _uid
detail, result, user_id, uid = await get_abyss_history_callback(callback_query.data)
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
if result == "empty_data":
await callback_query.answer(text="此按钮不可用", show_alert=True)
return
if result.startswith("p_"):
await self.get_abyss_history_page(update, user_id, uid, result)
return
data_id = int(result)
if detail:
await self.get_abyss_history_floor(update, data_id, detail)
return
await self.get_abyss_history_season(update, data_id)
async def abyss_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE", previous: bool):
callback_query = update.callback_query
user = update.effective_user
user_id = user.id
uid = IInlineUseData.get_uid_from_context(context)
self.log_user(update, logger.info, "查询深渊挑战总览数据 previous[%s]", previous)
notice = None
try:
async with self.helper.genshin_or_public(user_id, uid=uid) as client:
if not client.public:
await client.get_record_cards()
abyss_data, avatar_data = await self.get_rendered_pic_data(client, client.player_id, previous)
images = await self.get_rendered_pic(abyss_data, avatar_data, client.player_id, 0, False)
image = images[0]
except AbyssUnlocked: # 若深渊未解锁
notice = "还未解锁深渊哦~"
except NoMostKills: # 若深渊还未挑战
notice = "还没有挑战本次深渊呢,咕咕咕~"
except AbyssNotFoundError:
notice = "无法查询玩家挑战队伍详情,只能查询统计详情哦~"
except TooManyRequestPublicCookies:
notice = "查询次数太多,请您稍后重试"
if notice:
await callback_query.answer(notice, show_alert=True)
return
await image.edit_inline_media(callback_query)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
return [
IInlineUseData(
text="本期深渊挑战总览",
hash="abyss_current",
callback=partial(self.abyss_use_by_inline, previous=False),
player=True,
),
IInlineUseData(
text="上期深渊挑战总览",
hash="abyss_previous",
callback=partial(self.abyss_use_by_inline, previous=True),
player=True,
),
]

View File

@ -1,132 +0,0 @@
"""Recommend teams for Spiral Abyss"""
import re
from telegram import Update
from telegram.constants import ChatAction, ParseMode
from telegram.ext import CallbackContext, filters
from core.dependence.assets import AssetsService
from core.plugin import Plugin, handler
from core.services.template.services import TemplateService
from metadata.genshin import AVATAR_DATA
from metadata.shortname import idToName
from modules.apihelper.client.components.abyss import AbyssTeam as AbyssTeamClient
from plugins.tools.genshin import GenshinHelper
from utils.log import logger
class AbyssTeamPlugin(Plugin):
"""Recommend teams for Spiral Abyss"""
def __init__(
self,
template: TemplateService,
helper: GenshinHelper,
assets_service: AssetsService,
):
self.template_service = template
self.helper = helper
self.team_data = AbyssTeamClient()
self.assets_service = assets_service
@handler.command("abyss_team", block=False)
@handler.message(filters.Regex(r"^深渊配队"), block=False)
async def command_start(self, update: Update, _: CallbackContext) -> None: # skipcq: PY-R1000 #
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
if "help" in message.text or "帮助" in message.text:
await message.reply_text(
"<b>深渊配队推荐</b>功能使用帮助(中括号表示可选参数)\n\n"
"指令格式:\n<code>/abyss_team [n=配队数]</code>\n<code>pre</code>表示上期)\n\n"
"文本格式:\n<code>深渊配队 [n=配队数]</code> \n\n"
"如:\n"
"<code>/abyss_team</code>\n<code>/abyss_team n=5</code>\n"
"<code>深渊配队</code>\n",
parse_mode=ParseMode.HTML,
)
self.log_user(update, logger.info, "查询[bold]深渊配队推荐[/bold]帮助", extra={"markup": True})
return
self.log_user(update, logger.info, "[bold]深渊配队推荐[/bold]请求", extra={"markup": True})
client = await self.helper.get_genshin_client(user_id, player_id=uid, offset=offset)
await message.reply_chat_action(ChatAction.TYPING)
team_data = await self.team_data.get_data()
# Set of uids
characters = {c.id for c in await client.get_genshin_characters(client.player_id)}
teams = {
"Up": [],
"Down": [],
}
# All of the effective and available teams
for lane in ["Up", "Down"]:
for a_team in team_data[12 - 9][lane]:
t_characters = [int(s) for s in re.findall(r"\d+", a_team["Item"])]
t_rate = a_team["Rate"]
# Check availability
if not all(c in characters for c in t_characters):
continue
teams[lane].append(
{
"Characters": t_characters,
"Rate": t_rate,
}
)
# If a number is specified, use it as the number of expected teams.
match = re.search(r"(?<=n=)\d+", message.text)
n_team = int(match.group()) if match is not None else 4
if "fast" in message.text:
# TODO: Give it a faster method?
# Maybe we can allow characters exist on both side.
return
# Otherwise, we'd find a team in a complexity
# O(len(teams[up]) * len(teams[down]))
abyss_teams_data = {"uid": client.player_id, "teams": []}
async def _get_render_data(id_list):
return [
{
"icon": (await self.assets_service.avatar(cid).icon()).as_uri(),
"name": idToName(cid),
"star": AVATAR_DATA[str(cid)]["rank"] if cid not in {10000005, 10000007} else 5,
"hava": True,
}
for cid in id_list
]
for u in teams["Up"]:
for d in teams["Down"]:
if not all(c not in d["Characters"] for c in u["Characters"]):
continue
team = {
"Up": await _get_render_data(u["Characters"]),
"UpRate": u["Rate"],
"Down": await _get_render_data(d["Characters"]),
"DownRate": d["Rate"],
}
abyss_teams_data["teams"].append(team)
abyss_teams_data["teams"].sort(key=lambda t: t["UpRate"] * t["DownRate"], reverse=True)
abyss_teams_data["teams"] = abyss_teams_data["teams"][0 : min(n_team, len(abyss_teams_data["teams"]))]
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
render_result = await self.template_service.render(
"genshin/abyss_team/abyss_team.jinja2",
abyss_teams_data,
{"width": 785, "height": 800},
full_page=True,
query_selector=".bg-contain",
)
await render_result.reply_photo(message, filename=f"abyss_team_{user_id}.png")

View File

@ -1,120 +0,0 @@
from typing import TYPE_CHECKING, Optional
from telegram.constants import ChatAction
from telegram.ext import filters
from core.dependence.assets import AssetsService
from core.plugin import Plugin, handler
from core.services.template.models import FileType
from core.services.template.services import TemplateService
from gram_core.services.players import PlayersService
from metadata.genshin import AVATAR_DATA
from metadata.shortname import roleToName, roleToId
from modules.apihelper.client.components.akasha import Akasha
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
class AkashaPlugin(Plugin):
"""Akasha 数据排行"""
def __init__(
self,
assets_service: AssetsService = None,
template_service: TemplateService = None,
player_service: PlayersService = None,
) -> None:
self.assets_service = assets_service
self.template_service = template_service
self.player_service = player_service
async def get_user_uid(self, user_id: int, uid: int, offset: int) -> Optional[int]:
player = await self.player_service.get(user_id, player_id=uid, offset=offset)
if player is None:
return None
return player.player_id
@staticmethod
async def get_leaderboard_data(character_id: int, uid: int = None):
akasha = Akasha()
categories = await akasha.get_leaderboard_categories(character_id)
if len(categories) == 0 or len(categories[0].weapons) == 0:
raise NotImplementedError
calculation_id = categories[0].weapons[0].calculationId
count = categories[0].count
data = await akasha.get_leaderboard(calculation_id)
if len(data) == 0:
raise NotImplementedError
user_data = []
if uid:
user_data = await akasha.get_leaderboard(calculation_id, uid)
if len(user_data) == 0:
data = [data]
else:
data = [user_data, data]
return data, count
async def get_avatar_board_render_data(self, character: str, uid: int):
character_id = roleToId(character)
if not character_id:
raise NotImplementedError
try:
name_card = (await self.assets_service.namecard(character_id).navbar()).as_uri()
avatar = (await self.assets_service.avatar(character_id).icon()).as_uri()
except KeyError:
logger.warning("未找到角色 %s 的角色名片/头像", character_id)
name_card = None
avatar = None
rarity = 5
try:
rarity = {k: v["rank"] for k, v in AVATAR_DATA.items()}[str(character_id)]
except KeyError:
logger.warning("未找到角色 %s 的星级", character_id)
akasha_data, count = await self.get_leaderboard_data(character_id, uid)
return {
"character": character, # 角色名
"avatar": avatar, # 角色头像
"namecard": name_card, # 角色名片
"rarity": rarity, # 角色稀有度
"count": count,
"all_data": akasha_data,
}
@handler.command("avatar_board", block=False)
@handler.message(filters.Regex(r"^角色排名(.*)$"), block=False)
async def avatar_board(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
user_id = await self.get_real_user_id(update)
message = update.effective_message
args = self.get_args(context)
if len(args) == 0:
reply_message = await message.reply_text("请指定要查询的角色")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
avatar_name = roleToName(args[0])
uid, offset = self.get_real_uid_or_offset(update)
uid = await self.get_user_uid(user_id, uid, offset)
try:
render_data = await self.get_avatar_board_render_data(avatar_name, uid)
except NotImplementedError:
reply_message = await message.reply_text("暂不支持该角色,或者角色名称错误")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
image = await self.template_service.render(
"genshin/akasha/char_rank.jinja2",
render_data,
viewport={"width": 1040, "height": 500},
full_page=True,
query_selector=".container",
file_type=FileType.PHOTO,
ttl=24 * 60 * 60,
)
await image.reply_photo(message)

View File

@ -1,270 +0,0 @@
import asyncio
import math
from typing import List, Optional, Sequence, TYPE_CHECKING, Union, Tuple, Any, Dict
from arkowrapper import ArkoWrapper
from simnet import GenshinClient
from simnet.errors import BadRequest as SimnetBadRequest
from simnet.models.genshin.calculator import CalculatorTalent, CalculatorCharacterDetails
from simnet.models.genshin.chronicle.characters import Character
from telegram.constants import ChatAction
from telegram.ext import filters
from core.config import config
from core.dependence.assets import AssetsService
from core.plugin import Plugin, handler
from core.services.cookies import CookiesService
from core.services.players import PlayersService
from core.services.players.services import PlayerInfoService
from core.services.template.models import FileType
from core.services.template.services import TemplateService
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from gram_core.services.template.models import RenderGroupResult
from modules.wiki.base import Model
from plugins.tools.genshin import CharacterDetails, GenshinHelper
from plugins.tools.player_info import PlayerInfoSystem
from utils.log import logger
from utils.uid import mask_number
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
from gram_core.services.template.models import RenderResult
MAX_AVATAR_COUNT = 40
class TooManyRequests(Exception):
"""请求过多"""
class SkillData(Model):
"""天赋数据"""
skill: CalculatorTalent
buffed: bool = False
"""是否得到了命座加成"""
class AvatarData(Model):
avatar: Character
detail: CalculatorCharacterDetails
icon: str
weapon: Optional[str]
skills: List[SkillData]
def sum_of_skills(self) -> int:
total_level = 0
for skill_data in self.skills:
total_level += skill_data.skill.level
return total_level
class AvatarListPlugin(Plugin):
"""练度统计"""
def __init__(
self,
player_service: PlayersService = None,
cookies_service: CookiesService = None,
assets_service: AssetsService = None,
template_service: TemplateService = None,
helper: GenshinHelper = None,
character_details: CharacterDetails = None,
player_info_service: PlayerInfoService = None,
player_info_system: PlayerInfoSystem = None,
) -> None:
self.cookies_service = cookies_service
self.assets_service = assets_service
self.template_service = template_service
self.helper = helper
self.character_details = character_details
self.player_service = player_service
self.player_info_service = player_info_service
self.player_info_system = player_info_system
async def get_avatar_data(self, character: Character, client: "GenshinClient") -> Optional["AvatarData"]:
detail = await self.character_details.get_character_details(client, character)
if detail is None:
return None
if character.id == 10000005: # 针对男草主
talents = []
for talent in detail.talents:
if "普通攻击" in talent.name:
talent.Config.allow_mutation = True
# noinspection Pydantic
talent.group_id = 1131
if talent.type in ["attack", "skill", "burst"]:
talents.append(talent)
else:
talents = [t for t in detail.talents if t.type in ["attack", "skill", "burst"]]
buffed_talents = []
for constellation in filter(lambda x: x.pos in [3, 5], character.constellations[: character.constellation]):
if result := list(
filter(lambda x: all([x.name in constellation.effect]), talents) # pylint: disable=W0640
):
buffed_talents.append(result[0].type)
return AvatarData(
avatar=character,
detail=detail,
icon=(await self.assets_service.avatar(character.id).side()).as_uri(),
weapon=(
await self.assets_service.weapon(character.weapon.id).__getattr__(
"icon" if character.weapon.ascension < 2 else "awaken"
)()
).as_uri(),
skills=[
SkillData(skill=s, buffed=s.type in buffed_talents)
for s in sorted(talents, key=lambda x: ["attack", "skill", "burst"].index(x.type))
],
)
async def get_avatars_data(
self, characters: Sequence[Character], client: "GenshinClient", max_length: int = None
) -> List["AvatarData"]:
async def _task(c):
return await self.get_avatar_data(c, client)
task_results = await asyncio.gather(*[_task(character) for character in characters])
return sorted(
list(filter(lambda x: x, task_results)),
key=lambda x: (
x.avatar.level,
x.avatar.rarity,
x.sum_of_skills(),
x.avatar.constellation,
# TODO 如果加入武器排序条件需要把武器转化为图片url的处理后置
# x.weapon.level,
# x.weapon.rarity,
# x.weapon.refinement,
x.avatar.friendship,
),
reverse=True,
)[:max_length]
async def avatar_list_render(
self,
base_render_data: Dict,
avatar_datas: List[AvatarData],
only_one_page: bool,
) -> Union[Tuple[Any], List["RenderResult"], None]:
def render_task(start_id: int, c: List[AvatarData]):
_render_data = {
"avatar_datas": c, # 角色数据
"start_id": start_id, # 开始序号
}
_render_data.update(base_render_data)
return self.template_service.render(
"genshin/avatar_list/main.jinja2",
_render_data,
viewport={"width": 1040, "height": 500},
full_page=True,
query_selector=".container",
file_type=FileType.PHOTO,
ttl=30 * 24 * 60 * 60,
)
if only_one_page:
return [await render_task(0, avatar_datas)]
image_count = len(avatar_datas)
while image_count > MAX_AVATAR_COUNT:
image_count /= 2
image_count = math.ceil(image_count)
avatar_datas_group = [avatar_datas[i : i + image_count] for i in range(0, len(avatar_datas), image_count)]
tasks = [render_task(i * image_count, c) for i, c in enumerate(avatar_datas_group)]
return await asyncio.gather(*tasks)
async def render(
self, client: "GenshinClient", user_id: int, user_name: str, all_avatars: bool = False
) -> List["RenderResult"]:
characters = await client.get_genshin_characters(client.player_id)
avatar_datas: List[AvatarData] = await self.get_avatars_data(
characters, client, None if all_avatars else MAX_AVATAR_COUNT
)
if not avatar_datas:
raise TooManyRequests()
name_card, avatar, nickname, rarity = await self.player_info_system.get_player_info(
client.player_id, user_id, user_name
)
base_render_data = {
"uid": mask_number(client.player_id), # 玩家uid
"nickname": nickname, # 玩家昵称
"avatar": avatar, # 玩家头像
"rarity": rarity, # 玩家头像对应的角色星级
"namecard": name_card, # 玩家名片
"has_more": len(characters) != len(avatar_datas), # 是否显示了全部角色
}
return await self.avatar_list_render(base_render_data, avatar_datas, not all_avatars)
@handler.command("avatars", cookie=True, block=False)
@handler.message(filters.Regex(r"^(全部)?练度统计$"), cookie=True, block=False)
async def avatar_list(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE"):
user_id = await self.get_real_user_id(update)
user_name = self.get_real_user_name(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
all_avatars = "全部" in message.text or "all" in message.text # 是否发送全部角色
self.log_user(update, logger.info, "[bold]练度统计[/bold]: all=%s", all_avatars, extra={"markup": True})
try:
async with self.helper.genshin(user_id, player_id=uid, offset=offset) as client:
notice = await message.reply_text(f"{config.notice.bot_name}需要收集整理数据,还请耐心等待哦~")
self.add_delete_message_job(notice, delay=60)
await message.reply_chat_action(ChatAction.TYPING)
images = await self.render(client, user_id, user_name, all_avatars)
except TooManyRequests:
reply_message = await message.reply_html("服务器熟啦 ~ 请稍后再试")
self.add_delete_message_job(reply_message, delay=20)
return
except SimnetBadRequest as e:
if e.ret_code == -502002:
reply_message = await message.reply_html("请先在米游社中使用一次<b>养成计算器</b>后再使用此功能~")
self.add_delete_message_job(reply_message, delay=20)
return
raise e
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
for group in ArkoWrapper(images).group(10): # 每 10 张图片分一个组
await RenderGroupResult(results=group).reply_media_group(message, write_timeout=60)
self.log_user(
update,
logger.info,
"[bold]练度统计[/bold]发送图片成功",
extra={"markup": True},
)
async def avatar_list_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user_id = await self.get_real_user_id(update)
user_name = self.get_real_user_name(update)
uid = IInlineUseData.get_uid_from_context(context)
self.log_user(update, logger.info, "查询练度统计")
try:
async with self.helper.genshin(user_id, player_id=uid) as client:
images = await self.render(client, user_id, user_name)
render = images[0]
except TooManyRequests:
await callback_query.answer("服务器熟啦 ~ 请稍后再试", show_alert=True)
return
except SimnetBadRequest as e:
if e.ret_code == -502002:
await callback_query.answer("请先在米游社中使用一次养成计算器后再使用此功能~", show_alert=True)
return
raise e
await render.edit_inline_media(callback_query)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
return [
IInlineUseData(
text="练度统计",
hash="avatar_list",
callback=self.avatar_list_use_by_inline,
cookie=True,
player=True,
)
]

View File

@ -1,85 +0,0 @@
import re
from typing import TYPE_CHECKING
from telegram.ext import filters
from core.plugin import Plugin, handler
from core.services.cookies import CookiesService
from core.services.task.services import TaskCardServices
from core.services.users.services import UserService, UserAdminService
from metadata.genshin import AVATAR_DATA
from metadata.shortname import roleToId, roleToName
from plugins.tools.birthday_card import (
BirthdayCardSystem,
rm_starting_str,
)
from plugins.tools.genshin import GenshinHelper
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
class BirthdayPlugin(Plugin):
"""Birthday."""
def __init__(
self,
user_service: UserService,
helper: GenshinHelper,
cookie_service: CookiesService,
card_system: BirthdayCardSystem,
user_admin_service: UserAdminService,
card_service: TaskCardServices,
):
"""Load Data."""
self.user_service = user_service
self.cookie_service = cookie_service
self.helper = helper
self.card_system = card_system
self.user_admin_service = user_admin_service
self.card_service = card_service
@handler.command(command="birthday", block=False)
async def command_start(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
message = update.effective_message
args = self.get_args(context)
if len(args) >= 1:
msg = args[0]
self.log_user(update, logger.info, "查询角色生日命令请求 || 参数 %s", msg)
if re.match(r"\d{1,2}.\d{1,2}", msg):
try:
month = rm_starting_str(re.findall(r"\d+", msg)[0], "0")
day = rm_starting_str(re.findall(r"\d+", msg)[1], "0")
key = f"{month}_{day}"
day_list = self.card_system.birthday_list.get(key, [])
date = f"{month}{day}"
text = f"{date}{''.join(day_list)} 的生日哦~" if day_list else f"{date} 没有角色过生日哦~"
except IndexError:
text = "请输入正确的日期格式如1-1或输入正确的角色名称。"
reply_message = await message.reply_text(text)
else:
try:
if msg == "派蒙":
text = "派蒙的生日是6月1日哦~"
elif roleToName(msg) == "旅行者":
text = "喂,旅行者!你该不会忘掉自己的生日了吧?"
else:
name = roleToName(msg)
aid = str(roleToId(msg))
birthday = AVATAR_DATA[aid]["birthday"]
text = f"{name} 的生日是 {birthday[0]}{birthday[1]}日 哦~"
reply_message = await message.reply_text(text)
except KeyError:
reply_message = await message.reply_text("请输入正确的日期格式如1-1或输入正确的角色名称。")
else:
self.log_user(update, logger.info, "查询今日角色生日列表")
today_list = self.card_system.get_today_birthday()
text = f"今天是 {''.join(today_list)} 的生日哦~" if today_list else "今天没有角色过生日哦~"
reply_message = await message.reply_text(text)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)

View File

@ -1,90 +0,0 @@
from datetime import datetime, timedelta
from functools import partial
from typing import Dict, List, Optional, TYPE_CHECKING
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import CallbackContext, MessageHandler, filters
from core.dependence.assets import AssetsService
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.template.services import TemplateService
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from modules.apihelper.client.components.calendar import Calendar
from utils.log import logger
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
if TYPE_CHECKING:
from telegram.ext import ContextTypes
from gram_core.services.template.models import RenderResult
class CalendarPlugin(Plugin):
"""活动日历查询"""
def __init__(
self,
template_service: TemplateService,
assets_service: AssetsService,
redis: RedisDB,
):
self.template_service = template_service
self.assets_service = assets_service
self.calendar = Calendar()
self.cache = redis.client
async def _fetch_data(self) -> Dict:
if data := await self.cache.get("plugin:calendar"):
return jsonlib.loads(data.decode("utf-8"))
data = await self.calendar.get_photo_data(self.assets_service)
now = datetime.now()
next_hour = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
await self.cache.set("plugin:calendar", jsonlib.dumps(data, default=lambda x: x.dict()), ex=next_hour - now)
return data
async def render(self, list_mode: bool) -> "RenderResult":
data = await self._fetch_data()
data["display_mode"] = "list" if list_mode else "calendar"
return await self.template_service.render(
"genshin/calendar/calendar.jinja2",
data,
query_selector=".container",
)
@handler.command("calendar", block=False)
@handler(MessageHandler, filters=filters.Regex(r"^(活动)+(日历|日历列表)$"), block=False)
async def command_start(self, update: Update, _: CallbackContext) -> None:
message = update.effective_message
mode = "list" if "列表" in message.text else "calendar"
self.log_user(update, logger.info, "查询日历 | 模式 %s", mode)
await message.reply_chat_action(ChatAction.TYPING)
image = await self.render(mode == "list")
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
await image.reply_photo(message)
async def calendar_use_by_inline(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE", list_mode: bool):
callback_query = update.callback_query
self.log_user(update, logger.info, "查询日历 | 列表模式 %s", list_mode)
await callback_query.answer("正在查询日历,请耐心等待")
image = await self.render(list_mode)
await image.edit_inline_media(callback_query)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
return [
IInlineUseData(
text="活动日历",
hash="calendar",
callback=partial(self.calendar_use_by_inline, list_mode=False),
),
IInlineUseData(
text="活动日历列表",
hash="calendar_list",
callback=partial(self.calendar_use_by_inline, list_mode=True),
),
]

View File

@ -1,790 +0,0 @@
import asyncio
import typing
from asyncio import Lock
from ctypes import c_double
from datetime import datetime
from functools import partial
from multiprocessing import Value
from os import path
from ssl import SSLZeroReturnError
from time import time as time_
from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Tuple
import aiofiles
import aiofiles.os
import bs4
import pydantic
from arkowrapper import ArkoWrapper
from httpx import AsyncClient, HTTPError, TimeoutException
from pydantic import BaseModel
from simnet.errors import BadRequest as SimnetBadRequest
from simnet.errors import InvalidCookies
from simnet.models.genshin.chronicle.characters import Character
from telegram.constants import ChatAction, ParseMode
from telegram.error import RetryAfter, TimedOut
from core.config import config
from core.dependence.assets import AssetsCouldNotFound, AssetsService, AssetsServiceType
from core.plugin import Plugin, handler
from core.services.template.models import FileType, RenderGroupResult
from core.services.template.services import TemplateService
from metadata.genshin import AVATAR_DATA, HONEY_DATA
from plugins.tools.genshin import CharacterDetails, CookiesNotFoundError, GenshinHelper, PlayerNotFoundError
from utils.const import DATA_DIR
from utils.log import logger
from utils.uid import mask_number
if TYPE_CHECKING:
from simnet import GenshinClient
from telegram import Message, Update
from telegram.ext import ContextTypes
INTERVAL = 1
DATA_FILE_PATH = DATA_DIR.joinpath("daily_material.json").resolve()
# fmt: off
# 章节顺序、国家(区域)名是从《足迹》 PV 中取的
DOMAINS = [
"忘却之峡", # 蒙德精通秘境
"太山府", # 璃月精通秘境
"菫色之庭", # 稻妻精通秘境
"昏识塔", # 须弥精通秘境
"苍白的遗荣", # 枫丹精通秘境
"", # 纳塔精通秘境
"", # 至东精通秘境
"", # 坎瑞亚精通秘境
"塞西莉亚苗圃", # 蒙德炼武秘境
"震雷连山密宫", # 璃月炼武秘境
"砂流之庭", # 稻妻炼武秘境
"有顶塔", # 须弥炼武秘境
"深潮的余响", # 枫丹炼武秘境
"", # 纳塔炼武秘境
"", # 至东炼武秘境
"", # 坎瑞亚炼武秘境
]
# fmt: on
DOMAIN_AREA_MAP = dict(zip(DOMAINS, ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "至冬", "坎瑞亚"] * 2))
# 此处 avatar 和 weapon 需要分别对应 AreaDailyMaterialsData 中的两个 *_materials 字段,具体逻辑见 _parse_honey_impact_source
DOMAIN_TYPE_MAP = dict(zip(DOMAINS, len(DOMAINS) // 2 * ["avatar"] + len(DOMAINS) // 2 * ["weapon"]))
WEEK_MAP = ["", "", "", "", "", "", ""]
def sort_item(items: List["ItemData"]) -> Iterable["ItemData"]:
"""对武器和角色进行排序
排序规则持有星级 > 等级 > 命座/精炼) > 未持有星级 > 等级 > 命座/精炼
"""
def key(item: "ItemData"):
# 有个小小的意外逻辑,不影响排序输出,如果要修改可能需要注意下:
# constellation 可以为 None 或 0此时都会被判断为 False
# 此时 refinment or constellation or -1 都会返回 -1
# 但不影响排序结果
return (
item.level is not None, # 根据持有与未持有进行分组并排序
item.rarity, # 根据星级分组并排序
item.refinement or item.constellation or -1, # 根据命座/精炼进行分组并排序
item.id, # 默认按照物品 ID 进行排序
)
return sorted(items, key=key, reverse=True)
def get_material_serial_name(names: Iterable[str]) -> str:
"""
获取材料的系列名本质上是求字符串列表的最长子串
自由的教导自由的指引自由的哲学三者系列名为自由
高塔孤王的破瓦高塔孤王的残垣高塔孤王的断片高塔孤王的碎梦四者系列名为高塔孤王
TODO(xr1s): 感觉可以优化
"""
def all_substrings(string: str) -> Iterator[str]:
"""获取字符串的所有连续字串"""
length = len(string)
for i in range(length):
for j in range(i + 1, length + 1):
yield string[i:j]
result = []
for name_a, name_b in ArkoWrapper(names).repeat(1).group(2).unique(list):
for sub_string in all_substrings(name_a):
if sub_string in ArkoWrapper(all_substrings(name_b)):
result.append(sub_string)
result = ArkoWrapper(result).sort(len, reverse=True)[0]
chars = {"": 0, "": 0}
for char, k in chars.items():
result = result.split(char)[k]
return result
class MaterialsData(BaseModel):
__root__: Optional[List[Dict[str, "AreaDailyMaterialsData"]]] = None
def weekday(self, weekday: int) -> Dict[str, "AreaDailyMaterialsData"]:
if self.__root__ is None:
return {}
return self.__root__[weekday]
def is_empty(self) -> bool:
return self.__root__ is None
class DailyMaterial(Plugin):
"""每日素材表"""
everyday_materials: "MaterialsData" = MaterialsData()
"""
everyday_materials 储存的是一周中每天能刷的素材 ID
按照如下形式组织
```python
everyday_materials[周几][国家] = AreaDailyMaterialsData(
avatar=[角色, 角色, ...],
avatar_materials=[精通素材, 精通素材, 精通素材],
weapon=[武器, 武器, ...]
weapon_materials=[炼武素材, 炼武素材, 炼武素材, 炼武素材],
)
```
"""
locks: Tuple[Lock, Lock] = (Lock(), Lock())
"""
Tuple[每日素材缓存锁, 角色武器材料图标锁]
"""
def __init__(
self,
assets: AssetsService,
template_service: TemplateService,
helper: GenshinHelper,
character_details: CharacterDetails,
):
self.assets_service = assets
self.template_service = template_service
self.helper = helper
self.character_details = character_details
self.client = AsyncClient()
async def initialize(self):
"""插件在初始化时,会检查一下本地是否缓存了每日素材的数据"""
async def task_daily():
async with self.locks[0]:
logger.info("正在开始获取每日素材缓存")
await self._refresh_everyday_materials()
# 当缓存不存在或已过期(大于 3 天)则重新下载
# TODO(xr1s): 是不是可以改成 21 天?
if not await aiofiles.os.path.exists(DATA_FILE_PATH):
asyncio.create_task(task_daily())
else:
mtime = await aiofiles.os.path.getmtime(DATA_FILE_PATH)
mtime = datetime.fromtimestamp(mtime)
elapsed = datetime.now() - mtime
if elapsed.days > 3:
asyncio.create_task(task_daily())
# 若存在则直接使用缓存
if await aiofiles.os.path.exists(DATA_FILE_PATH):
async with aiofiles.open(DATA_FILE_PATH, "rb") as cache:
try:
self.everyday_materials = self.everyday_materials.parse_raw(await cache.read())
except pydantic.ValidationError:
await aiofiles.os.remove(DATA_FILE_PATH)
asyncio.create_task(task_daily())
async def _get_skills_data(self, client: "FragileGenshinClient", character: Character) -> Optional[List[int]]:
if client.damaged:
return None
detail = None
try:
real_client = typing.cast("GenshinClient", client.client)
detail = await self.character_details.get_character_details(real_client, character)
except InvalidCookies:
client.damaged = True
except SimnetBadRequest as e:
if e.ret_code == -502002:
client.damaged = True
raise
if detail is None:
return None
talents = [t for t in detail.talents if t.type in ["attack", "skill", "burst"]]
return [t.level for t in talents]
async def _get_items_from_user(
self, user_id: int, uid: int, offset: int
) -> Tuple[Optional["GenshinClient"], "UserOwned"]:
"""获取已经绑定的账号的角色、武器信息"""
user_data = UserOwned()
try:
logger.debug("尝试获取已绑定的原神账号")
client = await self.helper.get_genshin_client(user_id, player_id=uid, offset=offset)
logger.debug("获取账号数据成功: UID=%s", client.player_id)
characters = await client.get_genshin_characters(client.player_id)
for character in characters:
if character.name == "旅行者":
continue
character_id = str(AVATAR_DATA[str(character.id)]["id"])
character_assets = self.assets_service.avatar(character_id)
character_icon = await character_assets.icon(False)
character_side = await character_assets.side(False)
user_data.avatar[character_id] = ItemData(
id=character_id,
name=typing.cast(str, character.name),
rarity=int(typing.cast(str, character.rarity)),
level=character.level,
constellation=character.constellation,
gid=character.id,
icon=character_icon.as_uri(),
origin=character,
)
# 判定武器的突破次数是否大于 2, 若是, 则将图标替换为 awakened (觉醒) 的图标
weapon = character.weapon
weapon_id = str(weapon.id)
weapon_awaken = "icon" if weapon.ascension < 2 else "awaken"
weapon_icon = await getattr(self.assets_service.weapon(weapon_id), weapon_awaken)()
if weapon_id not in user_data.weapon:
# 由于用户可能持有多把同一种武器
# 这里需要使用 List 来储存所有不同角色持有的同名武器
user_data.weapon[weapon_id] = []
user_data.weapon[weapon_id].append(
ItemData(
id=weapon_id,
name=weapon.name,
level=weapon.level,
rarity=weapon.rarity,
refinement=weapon.refinement,
icon=weapon_icon.as_uri(),
c_path=character_side.as_uri(),
)
)
except (PlayerNotFoundError, CookiesNotFoundError):
self.log_user(user_id, logger.info, "未查询到绑定的账号信息")
except InvalidCookies:
self.log_user(user_id, logger.info, "所绑定的账号信息已失效")
else:
# 没有异常返回数据
return client, user_data
# 有上述异常的, client 会返回 None
return None, user_data
async def area_user_weapon(
self,
area_name: str,
user_owned: "UserOwned",
area_daily: "AreaDailyMaterialsData",
loading_prompt: "Message",
) -> Optional["AreaData"]:
"""
area_user_weapon 通过从选定区域当日可突破的武器中查找用户持有的武器
计算 /daily_material 返回的页面中该国下会出现的武器列表
"""
weapon_items: List["ItemData"] = []
for weapon_id in area_daily.weapon:
weapons = user_owned.weapon.get(weapon_id)
if weapons is None or len(weapons) == 0:
weapon = await self._assemble_item_from_honey_data("weapon", weapon_id)
if weapon is None:
continue
weapons = [weapon]
if weapons[0].rarity < 4:
continue
weapon_items.extend(weapons)
if len(weapon_items) == 0:
return None
weapon_materials = await self.user_materials(area_daily.weapon_materials, loading_prompt)
return AreaData(
name=area_name,
materials=weapon_materials,
items=list(sort_item(weapon_items)),
material_name=get_material_serial_name(map(lambda x: x.name, weapon_materials)),
)
async def area_user_avatar(
self,
area_name: str,
user_owned: "UserOwned",
area_daily: "AreaDailyMaterialsData",
client: "FragileGenshinClient",
loading_prompt: "Message",
) -> Optional["AreaData"]:
"""
area_user_avatar 通过从选定区域当日可升级的角色技能中查找用户拥有的角色
计算 /daily_material 返回的页面中该国下会出现的角色列表
"""
avatar_items: List[ItemData] = []
for avatar_id in area_daily.avatar:
avatar = user_owned.avatar.get(avatar_id)
avatar = avatar or await self._assemble_item_from_honey_data("avatar", avatar_id)
if avatar is None:
continue
if avatar.origin is None:
avatar_items.append(avatar)
continue
# 最大努力获取用户角色等级
try:
avatar.skills = await self._get_skills_data(client, avatar.origin)
except SimnetBadRequest as e:
if e.ret_code != -502002:
raise e
self.add_delete_message_job(loading_prompt, delay=5)
await loading_prompt.edit_text(
"获取角色天赋信息失败,如果想要显示角色天赋信息,请先在米游社/HoYoLab中使用一次<b>养成计算器</b>后再使用此功能~",
parse_mode=ParseMode.HTML,
)
avatar_items.append(avatar)
if len(avatar_items) == 0:
return None
avatar_materials = await self.user_materials(area_daily.avatar_materials, loading_prompt)
return AreaData(
name=area_name,
materials=avatar_materials,
items=list(sort_item(avatar_items)),
material_name=get_material_serial_name(map(lambda x: x.name, avatar_materials)),
)
async def user_materials(self, material_ids: List[str], loading_prompt: "Message") -> List["ItemData"]:
"""
user_materials 返回 /daily_material 每个国家角色或武器列表右上角标的素材列表
"""
area_materials: List[ItemData] = []
for material_id in material_ids: # 添加这个区域当天weekday的培养素材
material = None
try:
material = self.assets_service.material(material_id)
except AssetsCouldNotFound as exc:
logger.warning("AssetsCouldNotFound message[%s] target[%s]", exc.message, exc.target)
await loading_prompt.edit_text(f"出错了呜呜呜 ~ {config.notice.bot_name}找不到一些素材")
raise
[_, material_name, material_rarity] = HONEY_DATA["material"][material_id]
material_icon = await material.icon(False)
material_uri = material_icon.as_uri()
area_materials.append(
ItemData(
id=material_id,
icon=material_uri,
name=typing.cast(str, material_name),
rarity=typing.cast(int, material_rarity),
)
)
return area_materials
@handler.command("daily_material", block=False)
async def daily_material(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = typing.cast("Message", update.effective_message)
args = self.get_args(context)
now = datetime.now()
try:
weekday = (_ := int(args[0])) - (_ > 0)
weekday = (weekday % 7 + 7) % 7
time = title = f"星期{WEEK_MAP[weekday]}"
except (ValueError, IndexError):
title = "今日"
weekday = now.weekday() - (1 if now.hour < 4 else 0)
weekday = 6 if weekday < 0 else weekday
time = f"星期{WEEK_MAP[weekday]}"
full = bool(args and args[-1] == "full") # 判定最后一个参数是不是 full
self.log_user(update, logger.info, "每日素材命令请求 || 参数 weekday=%s full=%s", WEEK_MAP[weekday], full)
if weekday == 6:
the_day = "今天" if title == "今日" else "这天"
await message.reply_text(f"{the_day}是星期天, <b>全部素材都可以</b>刷哦~", parse_mode=ParseMode.HTML)
return
if self.locks[0].locked(): # 若检测到了第一个锁:正在下载每日素材表的数据
loading_prompt = await message.reply_text(f"{config.notice.bot_name}正在摘抄每日素材表,以后再来探索吧~")
self.add_delete_message_job(loading_prompt, delay=5)
return
if self.locks[1].locked(): # 若检测到了第二个锁:正在下载角色、武器、材料的图标
await message.reply_text(f"{config.notice.bot_name}正在搬运每日素材的图标,以后再来探索吧~")
return
loading_prompt = await message.reply_text(f"{config.notice.bot_name}可能需要找找图标素材,还请耐心等待哦~")
await message.reply_chat_action(ChatAction.TYPING)
# 获取已经缓存的秘境素材信息
if self.everyday_materials.is_empty(): # 若没有缓存每日素材表的数据
logger.info("正在获取每日素材缓存")
await self._refresh_everyday_materials()
# 尝试获取用户已绑定的原神账号信息
client, user_owned = await self._get_items_from_user(user_id, uid, offset)
today_materials = self.everyday_materials.weekday(weekday)
fragile_client = FragileGenshinClient(client)
area_avatars: List["AreaData"] = []
area_weapons: List["AreaData"] = []
for country_name, area_daily in today_materials.items():
area_avatar = await self.area_user_avatar(
country_name, user_owned, area_daily, fragile_client, loading_prompt
)
if area_avatar is not None:
area_avatars.append(area_avatar)
area_weapon = await self.area_user_weapon(country_name, user_owned, area_daily, loading_prompt)
if area_weapon is not None:
area_weapons.append(area_weapon)
render_data = RenderData(
title=title,
time=time,
uid=mask_number(client.player_id) if client else client,
character=area_avatars,
weapon=area_weapons,
)
await message.reply_chat_action(ChatAction.TYPING)
# 是否发送原图
file_type = FileType.DOCUMENT if full else FileType.PHOTO
character_img_data, weapon_img_data = await asyncio.gather(
self.template_service.render( # 渲染角色素材页
"genshin/daily_material/character.jinja2",
{"data": render_data},
{"width": 1338, "height": 500},
file_type=file_type,
ttl=30 * 24 * 60 * 60,
),
self.template_service.render( # 渲染武器素材页
"genshin/daily_material/weapon.jinja2",
{"data": render_data},
{"width": 1338, "height": 500},
file_type=file_type,
ttl=30 * 24 * 60 * 60,
),
)
self.add_delete_message_job(loading_prompt, delay=5)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
character_img_data.filename = f"{title}可培养角色.png"
weapon_img_data.filename = f"{title}可培养武器.png"
await RenderGroupResult([character_img_data, weapon_img_data]).reply_media_group(message)
logger.debug("角色、武器培养素材图发送成功")
@handler.command("refresh_daily_material", admin=True, block=False)
async def refresh(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
user = update.effective_user
message = update.effective_message
logger.info("用户 {%s}[%s] 刷新[bold]每日素材[/]缓存命令", user.full_name, user.id, extra={"markup": True})
if self.locks[0].locked():
notice = await message.reply_text(f"{config.notice.bot_name}还在抄每日素材表呢,我有在好好工作哦~")
self.add_delete_message_job(notice, delay=10)
return
if self.locks[1].locked():
notice = await message.reply_text(f"{config.notice.bot_name}正在搬运每日素材图标,在努力工作呢!")
self.add_delete_message_job(notice, delay=10)
return
async with self.locks[1]: # 锁住第二把锁
notice = await message.reply_text(
f"{config.notice.bot_name}正在重新摘抄每日素材表,请稍等~", parse_mode=ParseMode.HTML
)
async with self.locks[0]: # 锁住第一把锁
await self._refresh_everyday_materials()
notice = await notice.edit_text(
"每日素材表"
+ ("摘抄<b>完成!</b>" if self.everyday_materials else "坏掉了!等会它再长好了之后我再抄。。。")
+ "\n正搬运每日素材的图标中。。。",
parse_mode=ParseMode.HTML,
)
time = await self._download_icon(notice)
async def job(_, n):
await n.edit_text(n.text_html.split("\n")[0] + "\n每日素材图标搬运<b>完成!</b>", parse_mode=ParseMode.HTML)
await asyncio.sleep(INTERVAL)
await notice.delete()
context.application.job_queue.run_once(
partial(job, n=notice), when=time + INTERVAL, name="notice_msg_final_job"
)
async def _refresh_everyday_materials(self, retry: int = 5):
"""刷新来自 honey impact 的每日素材表"""
for attempts in range(1, retry + 1):
try:
response = await self.client.get("https://gensh.honeyhunterworld.com/?lang=CHS")
response.raise_for_status()
except (HTTPError, SSLZeroReturnError):
await asyncio.sleep(1)
if attempts == retry:
logger.error("每日素材刷新失败, 请稍后重试")
return
else:
logger.warning("每日素材刷新失败, 正在重试第 %d", attempts)
continue
self.everyday_materials = _parse_honey_impact_source(response.content)
# 当场缓存到文件
content = self.everyday_materials.json(ensure_ascii=False, separators=(",", ":"))
async with aiofiles.open(DATA_FILE_PATH, "w", encoding="utf-8") as file:
await file.write(content)
logger.success("每日素材刷新成功")
return
async def _assemble_item_from_honey_data(self, item_type: str, item_id: str) -> Optional["ItemData"]:
"""用户拥有的角色和武器中找不到数据时,使用 HoneyImpact 的数据组装出基本信息置灰展示"""
honey_item = HONEY_DATA[item_type].get(item_id)
if honey_item is None:
return None
try:
icon = await getattr(self.assets_service, item_type)(item_id).icon()
except KeyError:
return None
return ItemData(
id=item_id,
name=typing.cast(str, honey_item[1]),
rarity=typing.cast(int, honey_item[2]),
icon=icon.as_uri(),
)
async def _download_icon(self, message: "Message") -> float:
"""下载素材图标"""
asset_list = []
lock = asyncio.Lock()
the_time = Value(c_double, time_() - INTERVAL)
async def edit_message(text):
"""修改提示消息"""
async with lock:
if message is not None and time_() >= (the_time.value + INTERVAL):
try:
await message.edit_text(
"\n".join(message.text_html.split("\n")[:2] + [text]), parse_mode=ParseMode.HTML
)
the_time.value = time_()
except (TimedOut, RetryAfter):
pass
async def task(item_id, name, item_type):
try:
logger.debug("正在开始下载 %s 的图标素材", name)
await edit_message(f"正在搬运 <b>{name}</b> 的图标素材。。。")
asset: AssetsServiceType = getattr(self.assets_service, item_type)(item_id)
asset_list.append(asset.honey_id)
# 找到该素材对象的所有图标类型
# 并根据图标类型找到下载对应图标的函数
for icon_type in asset.icon_types:
await getattr(asset, icon_type)(True) # 执行下载函数
logger.debug("%s 的图标素材下载成功", name)
await edit_message(f"正在搬运 <b>{name}</b> 的图标素材。。。<b>成功!</b>")
except TimeoutException as exc:
logger.warning("Httpx [%s]\n%s[%s]", exc.__class__.__name__, exc.request.method, exc.request.url)
return exc
except Exception as exc:
logger.error("图标素材下载出现异常!", exc_info=exc)
return exc
notice_text = "图标素材下载完成"
for TYPE, ITEMS in HONEY_DATA.items(): # 遍历每个对象
task_list = []
new_items = []
for ID, DATA in ITEMS.items():
if (ITEM := [ID, DATA[1], TYPE]) not in new_items:
new_items.append(ITEM)
task_list.append(task(*ITEM))
results = await asyncio.gather(*task_list, return_exceptions=True) # 等待所有任务执行完成
for result in results:
if isinstance(result, TimeoutException):
notice_text = "图标素材下载过程中请求超时\n有关详细信息,请查看日志"
elif isinstance(result, Exception):
notice_text = "图标素材下载过程中发生异常\n有关详细信息,请查看日志"
break
try:
await message.edit_text(notice_text)
except RetryAfter as e:
await asyncio.sleep(e.retry_after + 0.1)
await message.edit_text(notice_text)
except Exception as e:
logger.debug(e)
logger.info("图标素材下载完成")
return the_time.value
def _parse_honey_impact_source(source: bytes) -> MaterialsData:
"""
## honeyimpact 的源码格式:
```html
<div class="calendar_day_wrap">
<!-- span 里记录秘境和对应突破素材 -->
<span class="item_secondary_title">
<a href="">秘境名<img src="" /></a>
<div data-days="0"> <!-- data-days 记录星期几 -->
<a href=""><img src="" /></a> <!-- 某某的教导ID href -->
<a href=""><img src="" /></a> <!-- 某某的指引ID href -->
<a href=""><img src="" /></a> <!-- 某某的哲学ID href -->
</div>
<div data-days="1"><!-- 同上但是星期二 --></div>
<div data-days="2"><!-- 同上但是星期三 --></div>
<div data-days="3"><!-- 同上但是星期四 --></div>
<div data-days="4"><!-- 同上但是星期五 --></div>
<div data-days="5"><!-- 同上但是星期六 --></div>
<div data-days="6"><!-- 同上但是星期日 --></div>
<span>
<!-- 这里开始是该秘境下所有可以刷的角色或武器的详细信息 -->
<!-- 注意这个 a 和上面的 span DOM 中是同级的 -->
<a href="">
<!-- data-days 储存可以刷素材的星期几 146 指的是 周二/周五/周日 -->
<div data-assign="char_编号" data-days="146" class="calendar_pic_wrap">
<img src="" /> <!-- Item ID 在此 -->
<span> 角色名 </span> <!-- 角色名周围的空格是切实存在的 -->
</div>
<!-- 以此类推该国家所有角色都会被列出 -->
</a>
<!-- 炼武秘境格式和精通秘境一样也是先 span a会把所有素材都列出来 -->
</div>
```
"""
honey_item_url_map: Dict[str, str] = { # 这个变量可以静态化,不过考虑到这个函数三天调用一次,懒得改了
typing.cast(str, honey_url): typing.cast(str, honey_id)
for honey_id, [honey_url, _, _] in HONEY_DATA["material"].items()
}
calendar = bs4.BeautifulSoup(source, "lxml").select_one(".calendar_day_wrap")
if calendar is None:
return MaterialsData() # 多半是格式错误或者网页数据有误
everyday_materials: List[Dict[str, "AreaDailyMaterialsData"]] = [{} for _ in range(7)]
current_country: str = ""
for element in calendar.find_all(recursive=False):
element: bs4.Tag
if element.name == "span": # 找到代表秘境的 span
domain_name = next(iter(element)).text # 第一个孩子节点的 text
current_country = DOMAIN_AREA_MAP[domain_name] # 后续处理 a 列表也会用到这个 current_country
materials_type = f"{DOMAIN_TYPE_MAP[domain_name]}_materials"
for div in element.find_all("div", recursive=False): # 7 个 div 对应的是一周中的每一天
div: bs4.Tag
weekday = int(div.attrs["data-days"]) # data-days 是一周中的第几天(周一 0周日 6
if current_country not in everyday_materials[weekday]:
everyday_materials[weekday][current_country] = AreaDailyMaterialsData()
materials: List[str] = getattr(everyday_materials[weekday][current_country], materials_type)
for a in div.find_all("a", recursive=False): # 当天能刷的所有素材在 a 列表中
a: bs4.Tag
href = a.attrs["href"] # 素材 ID 在 href 中
honey_url = path.dirname(href).removeprefix("/")
materials.append(honey_item_url_map[honey_url])
if element.name == "a":
# country_name 是从上面的 span 继承下来的,下面的 item 对应的是角色或者武器
# element 的第一个 child也就是 div.calendar_pic_wrap
calendar_pic_wrap = typing.cast(bs4.Tag, next(iter(element))) # element 的第一个孩子
item_name_span = calendar_pic_wrap.select_one("span")
if item_name_span is None or item_name_span.text.strip() == "旅行者":
continue # 因为旅行者的天赋计算比较复杂,不做旅行者的天赋计算
href = element.attrs["href"] # Item ID 在 href 中
item_is_weapon = href.startswith("/i_n")
# 角色 ID 前缀固定 10000但是 honey impact 替换成了角色名,剩余部分的数字是真正的 Item ID 组成部分
item_id = f"{'' if item_is_weapon else '10000'}{''.join(filter(str.isdigit, href))}"
for weekday in map(int, calendar_pic_wrap.attrs["data-days"]): # data-days 中存的是星期几可以刷素材
ascendable_items = everyday_materials[weekday][current_country]
ascendable_items = ascendable_items.weapon if item_is_weapon else ascendable_items.avatar
ascendable_items.append(item_id)
return MaterialsData(__root__=everyday_materials)
class FragileGenshinClient:
def __init__(self, client: Optional["GenshinClient"]):
self.client = client
self._damaged = False
@property
def damaged(self):
return self._damaged or self.client is None
@damaged.setter
def damaged(self, damaged: bool):
self._damaged = damaged
class ItemData(BaseModel):
id: str # ID
name: str # 名称
rarity: int # 星级
icon: str # 图标
level: Optional[int] = None # 等级
constellation: Optional[int] = None # 命座
skills: Optional[List[int]] = None # 天赋等级
gid: Optional[int] = None # 角色在 genshin.py 里的 ID
refinement: Optional[int] = None # 精炼度
c_path: Optional[str] = None # 武器使用者图标
origin: Optional[Character] = None # 原始数据
class AreaData(BaseModel):
name: str # 区域名
material_name: str # 区域的材料系列名
materials: List[ItemData] = [] # 区域材料
items: Iterable[ItemData] = [] # 可培养的角色或武器
class RenderData(BaseModel):
title: str # 页面标题,主要用于显示星期几
time: str # 页面时间
uid: Optional[str] = None # 用户UID
character: List[AreaData] = [] # 角色数据
weapon: List[AreaData] = [] # 武器数据
def __getitem__(self, item):
return self.__getattribute__(item)
class UserOwned(BaseModel):
avatar: Dict[str, ItemData] = {}
"""角色 ID 到角色对象的映射"""
weapon: Dict[str, List[ItemData]] = {}
"""用户同时可以拥有多把同名武器,因此是 ID 到 List 的映射"""
class AreaDailyMaterialsData(BaseModel):
"""
AreaDailyMaterialsData 储存某一天某个国家所有可以刷的突破素材以及可以突破的角色和武器
对应 /daily_material 命令返回的图中一个国家横向这一整条的信息
"""
avatar_materials: List[str] = []
"""
avatar_materials 是当日该国所有可以刷的精通和炼武素材的 ID 列表
举个例子稻妻周三可以刷天光系列材料
不用蒙德璃月举例是因为它们每天的角色武器太多了等稻妻多了再换
那么 avatar_materials 将会包括
- 104326 天光的教导
- 104327 天光的指引
- 104328 天光的哲学
"""
avatar: List[str] = []
"""
avatar 是排除旅行者后该国当日可以突破天赋的角色 ID 列表
举个例子稻妻周三可以刷天光系列精通素材
需要用到天光系列的角色有
- 10000052 雷电将军
- 10000053 早柚
- 10000055 五郎
- 10000058 八重神子
"""
weapon_materials: List[str] = []
"""
weapon_materials 是当日该国所有可以刷的炼武素材的 ID 列表
举个例子稻妻周三可以刷今昔剧画系列材料
那么 weapon_materials 将会包括
- 114033 今昔剧画之恶尉
- 114034 今昔剧画之虎啮
- 114035 今昔剧画之一角
- 114036 今昔剧画之鬼人
"""
weapon: List[str] = []
"""
weapon 是该国当日可以突破天赋的武器 ID 列表
举个例子稻妻周三可以刷今昔剧画系列炼武素材
需要用到今昔剧画系列的武器有
- 11416 笼钓瓶一心
- 13414 喜多院十文字
- 13415 渔获
- 13416 断浪长鳍
- 13509 薙草之稻光
- 14509 神乐之真意
"""
MaterialsData.update_forward_refs()

View File

@ -1,331 +0,0 @@
import copy
from typing import Optional, TYPE_CHECKING, List, Union, Dict, Tuple
from enkanetwork import EnkaNetworkResponse
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import filters
from core.config import config
from core.dependence.assets import AssetsService
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.players import PlayersService
from gram_core.services.template.services import TemplateService
from gram_core.services.users.services import UserAdminService
from metadata.shortname import roleToName, roleToId
from modules.gcsim.file import PlayerGCSimScripts
from modules.playercards.file import PlayerCardsFile
from plugins.genshin.gcsim.renderer import GCSimResultRenderer
from plugins.genshin.gcsim.runner import GCSimRunner, GCSimFit, GCSimQueueFull, GCSimResult
from plugins.genshin.model.base import CharacterInfo
from plugins.genshin.model.converters.enka import EnkaConverter
from plugins.tools.genshin import PlayerNotFoundError
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update, Message
from telegram.ext import ContextTypes
__all__ = ("GCSimPlugin",)
async def _no_character_return(user_id: int, uid: int, message: "Message"):
photo = open("resources/img/kitsune.png", "rb")
buttons = [
[
InlineKeyboardButton(
"更新面板",
callback_data=f"update_player_card|{user_id}|{uid}",
)
]
]
await message.reply_photo(
photo=photo,
caption="角色列表未找到,请尝试点击下方按钮从 Enka.Network 更新角色列表",
reply_markup=InlineKeyboardMarkup(buttons),
)
class GCSimPlugin(Plugin):
def __init__(
self,
assets_service: AssetsService,
player_service: PlayersService,
template_service: TemplateService,
redis: RedisDB = None,
user_admin_service: UserAdminService = None,
):
self.player_service = player_service
self.player_cards_file = PlayerCardsFile()
self.player_gcsim_scripts = PlayerGCSimScripts()
self.gcsim_runner = GCSimRunner(redis)
self.gcsim_renderer = GCSimResultRenderer(assets_service, template_service)
self.scripts_per_page = 8
self.user_admin_service = user_admin_service
async def initialize(self):
await self.gcsim_runner.initialize()
def _gen_buttons(
self, user_id: int, uid: int, fits: List[GCSimFit], page: int = 1
) -> List[List[InlineKeyboardButton]]:
buttons = []
for fit in fits[(page - 1) * self.scripts_per_page : page * self.scripts_per_page]:
button = InlineKeyboardButton(
f"{fit.script_key} ({','.join(map(str, fit.characters))})",
callback_data=f"enqueue_gcsim|{user_id}|{uid}|{fit.script_key}",
)
if not buttons or len(buttons[-1]) >= 1:
buttons.append([])
buttons[-1].append(button)
buttons.append(
[
(
InlineKeyboardButton("上一页", callback_data=f"gcsim_page|{user_id}|{uid}|{page - 1}")
if page > 1
else InlineKeyboardButton("更新配队", callback_data=f"gcsim_refresh|{user_id}|{uid}")
),
InlineKeyboardButton(
f"{page}/{int(len(fits) / self.scripts_per_page) + 1}",
callback_data=f"gcsim_unclickable|{user_id}|{uid}|unclickable",
),
(
InlineKeyboardButton("下一页", callback_data=f"gcsim_page|{user_id}|{uid}|{page + 1}")
if page < int(len(fits) / self.scripts_per_page) + 1
else InlineKeyboardButton(
"更新配队",
callback_data=f"gcsim_refresh|{user_id}|{uid}",
)
),
]
)
return buttons
@staticmethod
def _filter_fits_by_names(names: List[str], fits: List[GCSimFit]) -> List[GCSimFit]:
if not names:
return fits
return [fit for fit in fits if all(name in [str(i) for i in fit.characters] for name in names)]
async def _get_uid_names(
self,
user_id: int,
args: List[str],
reply: Optional["Message"],
player_id: int,
offset: int,
) -> Tuple[Optional[int], List[str]]:
"""通过消息获取 uid优先级args > reply > self"""
uid, user_id_, names = player_id, user_id, []
if args:
for i in args:
if i is not None and roleToId(i) is not None:
names.append(roleToName(i))
if reply:
try:
user_id_ = reply.from_user.id
except AttributeError:
pass
if not uid:
player_info = await self.player_service.get_player(user_id_, offset=offset)
if player_info is not None:
uid = player_info.player_id
if (not uid) and (user_id_ != user_id):
player_info = await self.player_service.get_player(user_id, offset=offset)
if player_info is not None:
uid = player_info.player_id
return uid, names
@staticmethod
def _fix_skill_level(data: Dict) -> Dict:
for i in data["avatarInfoList"]:
if "proudSkillExtraLevelMap" in i:
del i["proudSkillExtraLevelMap"]
return data
async def _load_characters(self, uid: Union[int, str]) -> List[CharacterInfo]:
original_data = await self.player_cards_file.load_history_info(uid)
if original_data is None:
return []
if len(original_data["avatarInfoList"]) == 0:
return []
enka_response: EnkaNetworkResponse = EnkaNetworkResponse.parse_obj(
self._fix_skill_level(copy.deepcopy(original_data))
)
character_infos = []
for avatar_info in enka_response.characters:
try:
character_infos.append(EnkaConverter.to_character_info(avatar_info))
except ValueError as e:
logger.error("无法解析 Enka.Network 角色信息: %s\n%s", e, avatar_info.json())
return character_infos
@handler.command(command="gcsim", block=False)
async def gcsim(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
user_id = await self.get_real_user_id(update)
message = update.effective_message
args = self.get_args(context)
if not self.gcsim_runner.initialized:
await message.reply_text(f"GCSim 未初始化,请稍候再试或重启{config.notice.bot_name}")
return
if context.user_data.get("overlapping", False):
reply = await message.reply_text(f"旅行者已经有脚本正在运行,请让{config.notice.bot_name}稍微休息一下")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply)
self.add_delete_message_job(message)
return
uid, offset = self.get_real_uid_or_offset(update)
uid, names = await self._get_uid_names(user_id, args, message.reply_to_message, uid, offset)
self.log_user(update, logger.info, "发出 gcsim 命令 UID[%s] NAMES[%s]", uid, " ".join(names))
if uid is None:
raise PlayerNotFoundError(user_id)
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user_id, uid, message)
fits = await self.gcsim_runner.get_fits(uid)
if not fits:
fits = await self.gcsim_runner.calculate_fits(uid, character_infos)
fits = self._filter_fits_by_names(names, fits)
if not fits:
await message.reply_text("好像没有找到适合旅行者的配队呢,要不更新下面板吧")
return
buttons = self._gen_buttons(user_id, uid, fits)
await message.reply_text(
"请选择 GCSim 脚本",
reply_markup=InlineKeyboardMarkup(buttons),
)
@handler.callback_query(pattern=r"^gcsim_refresh\|", block=False)
async def gcsim_refresh(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid = map(int, callback_query.data.split("|")[1:])
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user.id, uid, message)
await self.gcsim_runner.remove_fits(uid)
fits = await self.gcsim_runner.calculate_fits(uid, character_infos)
if not fits:
await callback_query.edit_message_text("好像没有找到适合旅行者的配队呢,要不更新下面板吧")
return
buttons = self._gen_buttons(user.id, uid, fits)
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^gcsim_page\|", block=False)
async def gcsim_page(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid, page = map(int, callback_query.data.split("|")[1:])
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
fits = await self.gcsim_runner.get_fits(uid)
if not fits:
await callback_query.answer(
text=f"其他数据好像被{config.notice.bot_name}吃掉了,要不重新试试吧", show_alert=True
)
await message.delete()
return
buttons = self._gen_buttons(user_id, uid, fits, page)
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^gcsim_unclickable\|", block=False)
async def gcsim_unclickable(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
_, _, _, reason = callback_query.data.split("|")
await callback_query.answer(
text=(
"已经是第一页了!\n"
if reason == "first_page"
else (
"已经是最后一页了!\n"
if reason == "last_page"
else "这个按钮不可用\n" + config.notice.user_mismatch
)
),
show_alert=True,
)
@handler.callback_query(pattern=r"^enqueue_gcsim\|", block=False)
async def enqueue_gcsim(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid, script_key = callback_query.data.split("|")[1:]
logger.info("用户 %s[%s] GCSim运行请求 || %s", user.full_name, user.id, callback_query.data)
if str(user.id) != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
logger.info("用户 %s[%s] enqueue_gcsim 运行请求 || %s", user.full_name, user.id, callback_query.data)
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user.id, uid, message)
await callback_query.edit_message_text(f"GCSim {script_key} 运行中...", reply_markup=InlineKeyboardMarkup([]))
results = []
callback_task = self._callback(update, results, character_infos)
priority = 1 if await self.user_admin_service.is_admin(user.id) else 2
try:
await self.gcsim_runner.run(user_id, uid, script_key, character_infos, results, callback_task, priority)
except GCSimQueueFull:
await callback_query.edit_message_text(f"{config.notice.bot_name}任务过多忙碌中,请稍后再试")
return
async def _callback(
self, update: "Update", results: List[GCSimResult], character_infos: List[CharacterInfo]
) -> None:
result = results[0]
callback_query = update.callback_query
message = callback_query.message
_, uid, script_key = callback_query.data.split("|")[1:]
msg_to_reply = message
if message.reply_to_message:
msg_to_reply = message.reply_to_message
if result.error:
await callback_query.edit_message_text(result.error)
else:
await callback_query.edit_message_text(f"GCSim {result.script_key} 运行完成")
if result.file_id:
await msg_to_reply.reply_photo(result.file_id, caption=f"GCSim {script_key} 运行结果")
self.add_delete_message_job(message, delay=1)
return
result_path = self.player_gcsim_scripts.get_result_path(uid, script_key)
if not result_path.exists():
await callback_query.answer(
text=f"运行结果似乎在提瓦特之外,{config.notice.bot_name}找不到了", show_alert=True
)
return
if result.script is None:
await callback_query.answer(text=f"脚本似乎在提瓦特之外,{config.notice.bot_name}找不到了", show_alert=True)
return
result_ = await self.gcsim_renderer.prepare_result(result_path, result.script, character_infos)
if not result_:
await callback_query.answer(text=f"在准备运行结果时{config.notice.bot_name}出问题了", show_alert=True)
return
render_result = await self.gcsim_renderer.render(script_key, result_)
reply = await render_result.reply_photo(
msg_to_reply,
filename=f"gcsim_{uid}_{script_key}.png",
caption=f"GCSim {script_key} 运行结果",
)
self.add_delete_message_job(message, delay=1)
if reply and reply.photo:
await self.gcsim_runner.cache.set_cache(uid, hash(str(result.script)), reply.photo[0].file_id)

View File

@ -1,141 +0,0 @@
import json
from pathlib import Path
from typing import Optional, List, TYPE_CHECKING
from core.dependence.assets import AssetsService
from gram_core.services.template.models import RenderResult
from gram_core.services.template.services import TemplateService
from metadata.shortname import idToName, elementToName, elementsToColor
from plugins.genshin.model import GCSim, GCSimCharacterInfo, CharacterInfo
from plugins.genshin.model.converters.gcsim import GCSimConverter
if TYPE_CHECKING:
from utils.typedefs import StrOrInt
class GCSimResultRenderer:
def __init__(self, assets_service: AssetsService, template_service: TemplateService):
self.assets_service = assets_service
self.template_service = template_service
@staticmethod
def fix_asset_id(asset_id: "StrOrInt") -> "StrOrInt":
if "-" in str(asset_id):
_asset_id = asset_id.split("-")[0]
if _asset_id.isnumeric():
return int(_asset_id)
return asset_id
async def prepare_result(
self, result_path: Path, script: GCSim, character_infos: List[CharacterInfo]
) -> Optional[dict]:
result = json.loads(result_path.read_text(encoding="utf-8"))
characters = {ch.character for ch in character_infos}
result["extra"] = {}
for idx, character_details in enumerate(result["character_details"]):
asset_id, _ = GCSimConverter.to_character(character_details["name"])
asset_id = self.fix_asset_id(asset_id)
gcsim_character: GCSimCharacterInfo = next(
filter(lambda gc, cn=character_details["name"]: gc.character == cn, script.characters), None
)
if not gcsim_character:
return None
if character_details["name"] not in result["extra"]:
result["extra"][character_details["name"]] = {}
if GCSimConverter.to_character(gcsim_character.character)[1] in characters:
result["extra"][character_details["name"]]["owned"] = True
else:
result["extra"][character_details["name"]]["owned"] = False
result["extra"][character_details["name"]]["icon"] = (
await self.assets_service.avatar(asset_id).icon()
).as_uri()
result["extra"][character_details["name"]]["rarity"] = self.assets_service.avatar(asset_id).enka.rarity
result["extra"][character_details["name"]]["constellation"] = gcsim_character.constellation
if "character_dps" not in result["extra"]:
result["extra"]["character_dps"] = []
result["extra"]["character_dps"].append(
{"value": result["statistics"]["character_dps"][idx]["mean"], "name": idToName(asset_id)}
)
result["extra"]["element_dps"] = [
{"value": data["mean"], "name": elementToName(elem), "itemStyle": {"color": elementsToColor[elem]}}
for elem, data in result["statistics"]["element_dps"].items()
]
result["extra"]["damage"] = {
"xAxis": [i * 0.5 for i in range(len(result["statistics"]["damage_buckets"]["buckets"]))],
"series": [
{
"data": [bucket["mean"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"symbol": "none",
"color": "#66ccff",
"name": "平均伤害",
},
{
"data": [bucket["min"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"lineStyle": {
"opacity": 0,
},
"stack": "area",
"symbol": "none",
},
{
"data": [
max(0, bucket["mean"] - bucket["sd"])
for bucket in result["statistics"]["damage_buckets"]["buckets"]
],
"type": "line",
"lineStyle": {"opacity": 0},
"stack": "cofidence-band",
"symbol": "none",
},
{
"data": [
min(bucket["mean"], bucket["sd"]) + bucket["sd"]
for bucket in result["statistics"]["damage_buckets"]["buckets"]
],
"type": "line",
"lineStyle": {
"opacity": 0,
},
"areaStyle": {
"opacity": 0.5,
"color": "#4c9bd4",
},
"stack": "cofidence-band",
"symbol": "none",
"color": "#4c9bd4",
"name": "标准差",
},
{
"data": [
bucket["max"] - bucket["min"] for bucket in result["statistics"]["damage_buckets"]["buckets"]
],
"type": "line",
"lineStyle": {
"opacity": 0,
},
"areaStyle": {
"opacity": 0.25,
"color": "#a5cde9",
},
"stack": "area",
"symbol": "none",
"color": "#a5cde9",
"name": "极值",
},
],
}
return result
async def render(self, script_key: str, data: dict) -> RenderResult:
return await self.template_service.render(
"genshin/gcsim/result.jinja2",
{"script_key": script_key, **data},
full_page=True,
query_selector="body > div",
ttl=7 * 24 * 60 * 60,
)

View File

@ -1,273 +0,0 @@
import asyncio
import multiprocessing
import platform
import time
from dataclasses import dataclass
from pathlib import Path
from queue import PriorityQueue
from typing import Optional, Dict, List, Union, TYPE_CHECKING, Tuple, Coroutine, Any
import gcsim_pypi
from pydantic import BaseModel
from gram_core.config import config
from metadata.shortname import idToName
from modules.apihelper.client.components.remote import Remote
from modules.gcsim.cache import GCSimCache
from modules.gcsim.file import PlayerGCSimScripts
from plugins.genshin.model.base import CharacterInfo, Character
from plugins.genshin.model.converters.gcsim import GCSimConverter
from plugins.genshin.model.gcsim import GCSim, GCSimCharacter
from utils.const import DATA_DIR
from utils.log import logger
if TYPE_CHECKING:
from core.dependence.redisdb import RedisDB
GCSIM_SCRIPTS_PATH = DATA_DIR / "gcsim" / "scripts"
GCSIM_SCRIPTS_PATH.mkdir(parents=True, exist_ok=True)
class FitCharacter(BaseModel):
id: int
name: str
gcsim: GCSimCharacter
character: Character
def __str__(self):
return self.name
class GCSimFit(BaseModel):
script_key: str
fit_count: int
characters: List[FitCharacter]
total_levels: int
total_weapon_levels: int
@dataclass
class GCSimResult:
error: Optional[str]
user_id: str
uid: str
script_key: str
script: Optional[GCSim] = None
file_id: Optional[str] = None
def _get_gcsim_bin_name() -> str:
if platform.system() == "Windows":
return "gcsim.exe"
bin_name = "gcsim"
if platform.system() == "Darwin":
bin_name += ".darwin"
if platform.machine() == "arm64":
bin_name += ".arm64"
return bin_name
def _get_limit_command() -> str:
if platform.system() == "Linux":
return "ulimit -m 1000000 && ulimit -v 1000000 && timeout 120 "
return ""
class GCSimRunnerTask:
def __init__(self, task: Coroutine[Any, Any, None]):
self.task = task
def __lt__(self, other: "GCSimRunnerTask") -> bool:
return False
async def run(self) -> None:
await self.task
class GCSimQueueFull(Exception):
pass
class GCSimRunner:
def __init__(self, client: "RedisDB"):
self.initialized = False
self.bin_path = None
self.player_gcsim_scripts = PlayerGCSimScripts()
self.gcsim_version: Optional[str] = None
self.scripts: Dict[str, GCSim] = {}
max_concurrent_gcsim = multiprocessing.cpu_count()
self.sema = asyncio.BoundedSemaphore(max_concurrent_gcsim)
self.queue_size = 21
self.queue: PriorityQueue[List[int, GCSimRunnerTask]] = PriorityQueue(maxsize=self.queue_size)
self.cache = GCSimCache(client)
@staticmethod
def check_gcsim_script(name: str, script: str) -> Optional[GCSim]:
try:
return GCSimConverter.from_gcsim_script(script)
except ValueError as e:
logger.error("无法解析 GCSim 脚本 %s: %s", name, e)
return None
async def refresh(self):
self.player_gcsim_scripts.clear_fits()
self.scripts.clear()
new_scripts = await Remote.get_gcsim_scripts()
for name, text in new_scripts.items():
if script := self.check_gcsim_script(name, text):
self.scripts[name] = script
for path in GCSIM_SCRIPTS_PATH.iterdir():
if path.is_file():
with open(path, "r", encoding="utf-8") as f:
try:
if script := self.check_gcsim_script(path.name, f.read()):
self.scripts[path.stem] = script
except UnicodeError as e:
logger.error("无法读取 GCSim 脚本 %s: %s", path.name, e)
async def initialize(self):
gcsim_pypi_path = Path(gcsim_pypi.__file__).parent
self.bin_path = gcsim_pypi_path.joinpath("bin").joinpath(_get_gcsim_bin_name())
process = await asyncio.create_subprocess_exec(
self.bin_path, "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
self.gcsim_version = stdout.decode().splitlines()[0]
logger.debug("GCSim version: %s", self.gcsim_version)
else:
logger.error("GCSim 运行时出错: %s", stderr.decode())
now = time.time()
await self.refresh()
logger.debug("加载 %d GCSim 脚本耗时 %.2f", len(self.scripts), time.time() - now)
self.initialized = True
@staticmethod
async def _execute_queue(
gcsim_task: Coroutine[Any, Any, GCSimResult],
results: List[GCSimResult],
callback_task: Coroutine[Any, Any, None],
) -> None:
data = await gcsim_task
results.append(data)
await callback_task
async def _execute_gcsim(
self,
user_id: str,
uid: str,
script_key: str,
added_time: float,
character_infos: List[CharacterInfo],
) -> GCSimResult:
script = self.scripts.get(script_key)
if script is None:
return GCSimResult(error="未找到脚本", user_id=user_id, uid=uid, script_key=script_key)
try:
merged_script = GCSimConverter.merge_character_infos(script.copy(), character_infos)
except ValueError:
return GCSimResult(error="无法合并角色信息", user_id=user_id, uid=uid, script_key=script_key)
if not config.debug:
if file_id := await self.cache.get_cache(uid, hash(str(merged_script))):
return GCSimResult(error=None, user_id=user_id, uid=uid, script_key=script_key, file_id=file_id)
await self.player_gcsim_scripts.write_script(uid, script_key, str(merged_script))
limit = _get_limit_command()
command = [
self.bin_path,
"-c",
self.player_gcsim_scripts.get_script_path(uid, script_key).absolute().as_posix(),
"-out",
self.player_gcsim_scripts.get_result_path(uid, script_key).absolute().as_posix(),
]
process = await asyncio.create_subprocess_shell(
limit + " ".join([str(i) for i in command]),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logger.debug("GCSim 脚本 (%s|%s|%s) 用时 %.2fs", user_id, uid, script_key, time.time() - added_time)
error = None
if stderr:
error = stderr.decode()[:500]
if "out of memory" in error:
error = "超出内存限制"
if process.returncode == 124:
error = "超出运行时间限制"
if error:
logger.error("GCSim 脚本 (%s|%s|%s) 错误: %s", user_id, uid, script_key, error)
return GCSimResult(error=error, user_id=user_id, uid=uid, script_key=script_key, script=merged_script)
if stdout:
logger.info("GCSim 脚本 (%s|%s|%s) 运行完成", user_id, uid, script_key)
logger.debug("GCSim 脚本 (%s|%s|%s) 输出: %s", user_id, uid, script_key, stdout.decode())
return GCSimResult(error=None, user_id=user_id, uid=uid, script_key=script_key, script=merged_script)
return GCSimResult(
error="No output",
user_id=user_id,
uid=uid,
script_key=script_key,
script=merged_script,
)
async def run(
self,
user_id: str,
uid: str,
script_key: str,
character_infos: List[CharacterInfo],
results: List[GCSimResult],
callback_task: Coroutine[Any, Any, None],
priority: int = 2,
) -> None:
start_time = time.time()
gcsim_task = self._execute_gcsim(user_id, uid, script_key, start_time, character_infos)
queue_task = GCSimRunnerTask(self._execute_queue(gcsim_task, results, callback_task))
if priority == 2 and self.queue.qsize() >= (self.queue_size - 1):
raise GCSimQueueFull()
if self.queue.full():
raise GCSimQueueFull()
self.queue.put([priority, queue_task])
async with self.sema:
if not self.queue.empty():
_, task = self.queue.get()
await task.run()
async def calculate_fits(self, uid: Union[int, str], character_infos: List[CharacterInfo]) -> List[GCSimFit]:
fits = []
for key, script in self.scripts.items():
# 空和莹会被认为是两个角色
fit_characters: List[Tuple[CharacterInfo, GCSimCharacter]] = []
for ch in character_infos:
gcsim_character = GCSimConverter.from_character(ch.character)
if gcsim_character in [c.character for c in script.characters]:
fit_characters.append((ch, gcsim_character))
if fit_characters:
fits.append(
GCSimFit(
script_key=key,
characters=[
FitCharacter(id=ch[0].id, name=idToName(ch[0].id), gcsim=ch[1], character=ch[0].character)
for ch in fit_characters
],
fit_count=len(fit_characters),
total_levels=sum(ch.level for ch in script.characters),
total_weapon_levels=sum(ch.weapon_info.level for ch in script.characters),
)
)
fits = sorted(
fits,
key=lambda x: (x.fit_count, x.total_levels, x.total_weapon_levels),
reverse=True,
)
await self.player_gcsim_scripts.write_fits(uid, [fit.dict() for fit in fits])
return fits
async def get_fits(self, uid: Union[int, str]) -> List[GCSimFit]:
return [GCSimFit(**fit) for fit in self.player_gcsim_scripts.get_fits(uid)]
async def remove_fits(self, uid: Union[int, str]) -> None:
self.player_gcsim_scripts.remove_fits(uid)

View File

@ -1,51 +0,0 @@
from typing import Dict
from aiofiles import open as async_open
from telegram import Update
from telegram.ext import CallbackContext, filters
from core.plugin import Plugin, handler
from utils.const import RESOURCE_DIR
from utils.log import logger
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
__all__ = ("HilichurlsPlugin",)
class HilichurlsPlugin(Plugin):
"""丘丘语字典."""
hilichurls_dictionary: Dict[str, str]
async def initialize(self) -> None:
"""加载数据文件.数据整理自 https://wiki.biligame.com/ys By @zhxycn."""
async with async_open(RESOURCE_DIR / "json/hilichurls_dictionary.json", encoding="utf-8") as file:
self.hilichurls_dictionary = jsonlib.loads(await file.read())
@handler.command(command="hilichurls", block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
args = self.get_args(context)
if len(args) >= 1:
msg = args[0]
else:
reply_message = await message.reply_text("请输入要查询的丘丘语。")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
search = str.casefold(msg) # 忽略大小写以方便查询
if search not in self.hilichurls_dictionary:
reply_message = await message.reply_text(f"在丘丘语字典中未找到 {msg}")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
self.log_user(update, logger.info, "查询今日角色生日列表 查询丘丘语字典命令请求 || 参数 %s", msg)
result = self.hilichurls_dictionary[f"{search}"]
await message.reply_markdown_v2(f"丘丘语: `{search}`\n\n`{result}`")

View File

@ -1,353 +0,0 @@
import math
import os
import re
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, List, Tuple, Optional
from simnet.errors import DataNotPublic, BadRequest as SimnetBadRequest
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
from telegram.constants import ChatAction
from telegram.ext import filters
from core.plugin import Plugin, handler
from core.services.cookies import CookiesService
from core.services.history_data.models import HistoryDataLedger
from core.services.history_data.services import HistoryDataLedgerServices
from core.services.template.models import RenderResult
from core.services.template.services import TemplateService
from gram_core.config import config
from gram_core.dependence.redisdb import RedisDB
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from plugins.tools.genshin import GenshinHelper
from utils.enkanetwork import RedisCache
from utils.log import logger
from utils.uid import mask_number
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
from simnet import GenshinClient
from simnet.models.genshin.diary import Diary
__all__ = ("LedgerPlugin",)
class LedgerPlugin(Plugin):
"""旅行札记查询"""
def __init__(
self,
helper: GenshinHelper,
cookies_service: CookiesService,
template_service: TemplateService,
history_data_ledger: HistoryDataLedgerServices,
redis: RedisDB,
):
self.template_service = template_service
self.cookies_service = cookies_service
self.current_dir = os.getcwd()
self.helper = helper
self.history_data_ledger = history_data_ledger
self.cache = RedisCache(redis.client, key="plugin:ledger:history")
self.kitsune = None
async def _start_get_ledger(self, client: "GenshinClient", month=None) -> RenderResult:
diary_info = await client.get_genshin_diary(client.player_id, month=month)
if month:
await self.save_ledger_data(self.history_data_ledger, client.player_id, diary_info)
return await self._start_get_ledger_render(client.player_id, diary_info)
async def _start_get_ledger_render(self, uid: int, diary_info: "Diary") -> RenderResult:
color = ["#73a9c6", "#d56565", "#70b2b4", "#bd9a5a", "#739970", "#7a6da7", "#597ea0"]
categories = [
{
"id": i.id,
"name": i.name,
"color": color[i.id % len(color)],
"amount": i.amount,
"percentage": i.percentage,
}
for i in diary_info.month_data.categories
]
color = [i["color"] for i in categories]
def format_amount(amount: int) -> str:
return f"{round(amount / 10000, 2)}w" if amount >= 10000 else amount
ledger_data = {
"uid": mask_number(uid),
"day": diary_info.month,
"current_primogems": format_amount(diary_info.month_data.current_primogems),
"gacha": int(diary_info.month_data.current_primogems / 160),
"current_mora": format_amount(diary_info.month_data.current_mora),
"last_primogems": format_amount(diary_info.month_data.last_primogems),
"last_gacha": int(diary_info.month_data.last_primogems / 160),
"last_mora": format_amount(diary_info.month_data.last_mora),
"categories": categories,
"color": color,
}
render_result = await self.template_service.render(
"genshin/ledger/ledger.jinja2", ledger_data, {"width": 580, "height": 610}
)
return render_result
@handler.command(command="ledger", cookie=True, block=False)
@handler.message(filters=filters.Regex("^旅行札记查询(.*)"), block=False)
async def command_start(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
now = datetime.now()
now_time = (now - timedelta(days=1)) if now.day == 1 and now.hour <= 4 else now
month = now_time.month
try:
args = self.get_args(context)
if len(args) >= 1:
month = args[0].replace("", "")
if re_data := re.findall(r"\d+", str(month)):
month = int(re_data[0])
else:
num_dict = {"": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10}
month = sum(num_dict.get(i, 0) for i in str(month))
# check right
allow_month = [now_time.month]
last_month = now_time.replace(day=1) - timedelta(days=1)
allow_month.append(last_month.month)
last_month = last_month.replace(day=1) - timedelta(days=1)
allow_month.append(last_month.month)
if month not in allow_month and isinstance(month, int):
raise IndexError
except IndexError:
reply_message = await message.reply_text("仅可查询最新三月的数据,请重新输入")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_message, delay=30)
self.add_delete_message_job(message, delay=30)
return
self.log_user(update, logger.info, "查询旅行札记")
await message.reply_chat_action(ChatAction.TYPING)
try:
async with self.helper.genshin(user_id, player_id=uid, offset=offset) as client:
render_result = await self._start_get_ledger(client, month)
except DataNotPublic:
reply_message = await message.reply_text(
"查询失败惹,可能是旅行札记功能被禁用了?请先通过米游社或者 hoyolab 获取一次旅行札记后重试。"
)
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_message, delay=30)
self.add_delete_message_job(message, delay=30)
return
except SimnetBadRequest as exc:
if exc.ret_code == -120:
await message.reply_text("当前角色冒险等阶不足,暂时无法获取信息")
return
raise exc
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
await render_result.reply_photo(message, filename=f"{client.player_id}.png")
@staticmethod
async def save_ledger_data(
history_data_ledger: "HistoryDataLedgerServices", uid: int, ledger_data: "Diary"
) -> bool:
month = int((ledger_data.date or datetime.now().strftime("%Y-%m-%d")).split("-")[1])
if month == ledger_data.month:
return False
model = history_data_ledger.create(uid, ledger_data)
old_data = await history_data_ledger.get_by_user_id_data_id(uid, model.data_id)
if not old_data:
await history_data_ledger.add(model)
return True
return False
async def get_ledger_data(self, uid: int):
return await self.history_data_ledger.get_by_user_id(uid)
@staticmethod
def get_season_data_name(data: "HistoryDataLedger") -> str:
return f"{data.diary_data.data_id}"
async def get_session_button_data(self, user_id: int, uid: int, force: bool = False):
redis = await self.cache.get(str(uid))
if redis and not force:
return redis["buttons"]
data = await self.get_ledger_data(uid)
data.sort(key=lambda x: x.data_id, reverse=True)
abyss_data = [HistoryDataLedger.from_data(i) for i in data]
buttons = [
{
"name": LedgerPlugin.get_season_data_name(abyss_data[idx]),
"value": f"get_ledger_history|{user_id}|{uid}|{value.id}",
}
for idx, value in enumerate(data)
]
await self.cache.set(str(uid), {"buttons": buttons})
return buttons
async def gen_season_button(
self,
user_id: int,
uid: int,
page: int = 1,
) -> List[List[InlineKeyboardButton]]:
"""生成按钮"""
data = await self.get_session_button_data(user_id, uid)
if not data:
return []
buttons = [
InlineKeyboardButton(
value["name"],
callback_data=value["value"],
)
for value in data
]
all_buttons = [buttons[i : i + 3] for i in range(0, len(buttons), 3)]
send_buttons = all_buttons[(page - 1) * 5 : page * 5]
last_page = page - 1 if page > 1 else 0
all_page = math.ceil(len(all_buttons) / 5)
next_page = page + 1 if page < all_page and all_page > 1 else 0
last_button = []
if last_page:
last_button.append(
InlineKeyboardButton(
"<< 上一页",
callback_data=f"get_ledger_history|{user_id}|{uid}|p_{last_page}",
)
)
if last_page or next_page:
last_button.append(
InlineKeyboardButton(
f"{page}/{all_page}",
callback_data=f"get_ledger_history|{user_id}|{uid}|empty_data",
)
)
if next_page:
last_button.append(
InlineKeyboardButton(
"下一页 >>",
callback_data=f"get_ledger_history|{user_id}|{uid}|p_{next_page}",
)
)
if last_button:
send_buttons.append(last_button)
return send_buttons
@handler.command("ledger_history", block=False)
@handler.message(filters.Regex(r"^旅行札记历史数据"), block=False)
async def ledger_history_command_start(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
self.log_user(update, logger.info, "查询旅行札记历史数据")
async with self.helper.genshin(user_id, player_id=uid, offset=offset) as client:
await self.get_session_button_data(user_id, client.player_id, force=True)
buttons = await self.gen_season_button(user_id, client.player_id)
if not buttons:
await message.reply_text("还没有旅行札记历史数据哦~")
return
if isinstance(self.kitsune, str):
photo = self.kitsune
else:
photo = open("resources/img/kitsune.png", "rb")
reply_message = await message.reply_photo(
photo, "请选择要查询的旅行札记历史数据", reply_markup=InlineKeyboardMarkup(buttons)
)
if reply_message.photo:
self.kitsune = reply_message.photo[-1].file_id
async def get_ledger_history_page(self, update: "Update", user_id: int, uid: int, result: str):
"""翻页处理"""
callback_query = update.callback_query
self.log_user(update, logger.info, "切换旅行札记历史数据页 page[%s]", result)
page = int(result.split("_")[1])
async with self.helper.genshin(user_id, player_id=uid) as client:
buttons = await self.gen_season_button(user_id, client.player_id, page)
if not buttons:
await callback_query.answer("还没有旅行札记历史数据哦~", show_alert=True)
await callback_query.edit_message_text("还没有旅行札记历史数据哦~")
return
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
await callback_query.answer(f"已切换到第 {page}", show_alert=False)
@handler.callback_query(pattern=r"^get_ledger_history\|", block=False)
async def get_ledger_history(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
message = callback_query.message
user = callback_query.from_user
async def get_ledger_history_callback(
callback_query_data: str,
) -> Tuple[str, int, int]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_uid = int(_data[2])
_result = _data[3]
logger.debug(
"callback_query_data函数返回 result[%s] user_id[%s] uid[%s]",
_result,
_user_id,
_uid,
)
return _result, _user_id, _uid
result, user_id, uid = await get_ledger_history_callback(callback_query.data)
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
if result == "empty_data":
await callback_query.answer(text="此按钮不可用", show_alert=True)
return
if result.startswith("p_"):
await self.get_ledger_history_page(update, user_id, uid, result)
return
data_id = int(result)
data = await self.history_data_ledger.get_by_id(data_id)
if not data:
await callback_query.answer("数据不存在,请尝试重新发送命令", show_alert=True)
await callback_query.edit_message_text("数据不存在,请尝试重新发送命令~")
return
await callback_query.answer("正在渲染图片中 请稍等 请不要重复点击按钮")
render = await self._start_get_ledger_render(user_id, HistoryDataLedger.from_data(data).diary_data)
await render.edit_media(message)
async def ledger_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
callback_query = update.callback_query
user = update.effective_user
user_id = user.id
uid = IInlineUseData.get_uid_from_context(context)
self.log_user(update, logger.info, "查询旅行札记")
try:
async with self.helper.genshin(user_id, player_id=uid) as client:
render_result = await self._start_get_ledger(client)
except DataNotPublic:
await callback_query.answer(
"查询失败惹,可能是旅行札记功能被禁用了?请先通过米游社或者 hoyolab 获取一次旅行札记后重试。",
show_alert=True,
)
return
except SimnetBadRequest as exc:
if exc.ret_code == -120:
await callback_query.answer(
"当前角色冒险等阶不足,暂时无法获取信息",
show_alert=True,
)
return
raise exc
await render_result.edit_inline_media(callback_query)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
return [
IInlineUseData(
text="当月旅行札记",
hash="ledger",
callback=self.ledger_use_by_inline,
cookie=True,
player=True,
)
]

View File

@ -1,197 +0,0 @@
from io import BytesIO
from typing import Union, Optional, List, Tuple
from telegram import Update, Message, InputMediaDocument, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction
from telegram.ext import CommandHandler, MessageHandler, filters, CallbackContext
from core.config import config
from core.dependence.redisdb import RedisDB
from core.handler.callbackqueryhandler import CallbackQueryHandler
from core.plugin import handler, Plugin
from modules.apihelper.client.components.map import MapHelper, MapException
from utils.log import logger
class Map(Plugin):
"""资源点查询"""
def __init__(self, redis: RedisDB):
self.cache = redis.client
self.cache_photo_key = "plugin:map:photo:"
self.cache_doc_key = "plugin:map:doc:"
self.map_helper = MapHelper()
self.temp_photo_path = "resources/img/map.png"
self.temp_photo = None
async def get_photo_cache(self, map_id: Union[str, int], name: str) -> Optional[str]:
if file_id := await self.cache.get(f"{self.cache_photo_key}{map_id}:{name}"):
return file_id.decode("utf-8")
return None
async def get_doc_cache(self, map_id: Union[str, int], name: str) -> Optional[str]:
if file_id := await self.cache.get(f"{self.cache_doc_key}{map_id}:{name}"):
return file_id.decode("utf-8")
return None
async def set_photo_cache(self, map_id: Union[str, int], name: str, file_id: str) -> None:
await self.cache.set(f"{self.cache_photo_key}{map_id}:{name}", file_id)
async def set_doc_cache(self, map_id: Union[str, int], name: str, file_id: str) -> None:
await self.cache.set(f"{self.cache_doc_key}{map_id}:{name}", file_id)
async def clear_cache(self) -> None:
for i in await self.cache.keys(f"{self.cache_photo_key}*"):
await self.cache.delete(i)
for i in await self.cache.keys(f"{self.cache_doc_key}*"):
await self.cache.delete(i)
async def edit_media(self, message: Message, map_id: str, name: str) -> None:
caption = self.gen_caption(map_id, name)
if cache := await self.get_photo_cache(map_id, name):
media = InputMediaPhoto(media=cache, caption=caption)
await message.edit_media(media)
return
if cache := await self.get_doc_cache(map_id, name):
media = InputMediaDocument(media=cache, caption=caption)
await message.edit_media(media)
return
data = await self.map_helper.get_map(map_id, name)
if len(data) > (1024 * 1024):
data = BytesIO(data)
data.name = "map.jpg"
media = InputMediaDocument(media=data, caption=caption)
msg = await message.edit_media(media)
await self.set_doc_cache(map_id, name, msg.document.file_id)
else:
media = InputMediaPhoto(media=data, caption=caption)
msg = await message.edit_media(media)
await self.set_photo_cache(map_id, name, msg.photo[0].file_id)
def get_show_map(self, name: str) -> List[int]:
return [
idx
for idx, map_id in enumerate(self.map_helper.MAP_ID_LIST)
if self.map_helper.get_label_count(map_id, name) > 0
]
async def gen_map_button(
self, maps: List[int], user_id: Union[str, int], name: str
) -> List[List[InlineKeyboardButton]]:
return [
[
InlineKeyboardButton(
self.map_helper.MAP_NAME_LIST[idx],
callback_data=f"get_map|{user_id}|{self.map_helper.MAP_ID_LIST[idx]}|{name}",
)
for idx in maps
]
]
async def send_media(self, message: Message, map_id: Union[str, int], name: str) -> None:
caption = self.gen_caption(map_id, name)
if cache := await self.get_photo_cache(map_id, name):
await message.reply_photo(photo=cache, caption=caption)
return
if cache := await self.get_doc_cache(map_id, name):
await message.reply_document(document=cache, caption=caption)
return
try:
data = await self.map_helper.get_map(map_id, name)
except MapException as e:
await message.reply_text(e.message)
return
if len(data) > (1024 * 1024):
data = BytesIO(data)
data.name = "map.jpg"
msg = await message.reply_document(document=data, caption=caption)
await self.set_doc_cache(map_id, name, msg.document.file_id)
else:
msg = await message.reply_photo(photo=data, caption=caption)
await self.set_photo_cache(map_id, name, msg.photo[0].file_id)
def gen_caption(self, map_id: Union[int, str], name: str) -> str:
count = self.map_helper.get_label_count(map_id, name)
return f"{config.notice.bot_name}一共找到了 {name}{count} 个位置点\n* 数据来源于米游社wiki"
@handler(CommandHandler, command="map", block=False)
@handler(
MessageHandler, filters=filters.Regex("^(?P<name>.*)(在哪里|在哪|哪里有|哪儿有|哪有|在哪儿)$"), block=False
)
@handler(MessageHandler, filters=filters.Regex("^(哪里有|哪儿有|哪有)(?P<name>.*)$"), block=False)
async def command_start(self, update: Update, context: CallbackContext):
user_id = await self.get_real_user_id(update)
message = update.effective_message
args = context.args
group_dict = context.match and context.match.groupdict()
resource_name = None
await message.reply_chat_action(ChatAction.TYPING)
if args and len(args) >= 1:
resource_name = args[0]
elif group_dict:
resource_name = group_dict.get("name", None)
if not resource_name:
if group_dict:
return
await message.reply_text("请指定要查找的资源名称。", parse_mode="Markdown")
return
self.log_user(update, logger.info, "使用 map 命令查询了 %s", resource_name)
if resource_name not in self.map_helper.query_map:
# 消息来源于群组中并且无法找到默认不回复即可
if filters.ChatType.GROUPS.filter(message) and group_dict is not None:
return
await message.reply_text("没有找到该资源。", parse_mode="Markdown")
return
maps = self.get_show_map(resource_name)
if len(maps) == 0:
if filters.ChatType.GROUPS.filter(message) and group_dict is not None:
return
await message.reply_text("没有找到该资源。", parse_mode="Markdown")
return
if len(maps) == 1:
map_id = self.map_helper.MAP_ID_LIST[maps[0]]
await self.send_media(message, map_id, resource_name)
return
buttons = await self.gen_map_button(maps, user_id, resource_name)
if isinstance(self.temp_photo, str):
photo = self.temp_photo
else:
photo = open(self.temp_photo_path, "rb")
reply_message = await message.reply_photo(
photo=photo, caption="请选择你要查询的地图", reply_markup=InlineKeyboardMarkup(buttons)
)
if reply_message.photo:
self.temp_photo = reply_message.photo[-1].file_id
@handler(CallbackQueryHandler, pattern=r"^get_map\|", block=False)
async def get_maps(self, update: Update, _: CallbackContext) -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
async def get_map_callback(callback_query_data: str) -> Tuple[int, str, str]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_map_id = _data[2]
_name = _data[3]
logger.debug("callback_query_data 函数返回 user_id[%s] map_id[%s] name[%s]", _user_id, _map_id, _name)
return _user_id, _map_id, _name
user_id, map_id, name = await get_map_callback(callback_query.data)
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
await callback_query.answer(text="正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False)
try:
await self.edit_media(message, map_id, name)
except MapException as e:
await message.reply_text(e.message)
@handler.command("refresh_map", admin=True, block=False)
async def refresh_map(self, update: Update, _: CallbackContext):
message = update.effective_message
msg = await message.reply_text("正在刷新地图数据,请耐心等待...")
await self.map_helper.refresh_query_map()
await self.map_helper.refresh_label_count()
await self.clear_cache()
await msg.edit_text("正在刷新地图数据,请耐心等待...\n刷新成功")

View File

@ -1,239 +0,0 @@
import re
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatAction
from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters
from core.dependence.assets import AssetsService
from core.plugin import Plugin, handler
from core.services.template.services import TemplateService
from metadata.genshin import MATERIAL_DATA
from metadata.shortname import roleToName
from modules.apihelper.client.components.remote import Remote
from modules.material.talent import TalentMaterials
from modules.wiki.character import Character
from utils.log import logger
__all__ = ("MaterialPlugin",)
class MaterialPlugin(Plugin):
"""角色培养素材查询"""
KEYBOARD = [
[
InlineKeyboardButton(
text="查看角色培养素材列表并查询", switch_inline_query_current_chat="查看角色培养素材列表并查询"
)
]
]
def __init__(
self,
template_service: TemplateService,
assets_service: AssetsService,
):
self.roles_material = {}
self.assets_service = assets_service
self.template_service = template_service
async def initialize(self):
await self._refresh()
async def _refresh(self):
self.roles_material = await Remote.get_remote_material()
async def _parse_material(self, data: dict, character_name: str, talent_level: str) -> dict:
data = data["data"]
if character_name not in data.keys():
return {}
character = self.assets_service.avatar(character_name)
level_up_material = self.assets_service.material(data[character_name]["level_up_materials"])
ascension_material = self.assets_service.material(data[character_name]["ascension_materials"])
local_material = self.assets_service.material(data[character_name]["materials"][0])
enemy_material = self.assets_service.material(data[character_name]["materials"][1])
level_up_materials = [
{
"num": 46,
"rarity": MATERIAL_DATA[str(level_up_material.id)]["rank"],
"icon": (await level_up_material.icon()).as_uri(),
"name": data[character_name]["level_up_materials"],
},
{
"num": 419,
"rarity": 4,
"icon": (await self.assets_service.material(104003).icon()).as_uri(),
"name": "大英雄的经验",
},
{
"num": 1,
"rarity": 2,
"icon": (await self.assets_service.material(ascension_material.id - 3).icon()).as_uri(),
"name": MATERIAL_DATA[str(ascension_material.id - 3)]["name"],
},
{
"num": 9,
"rarity": 3,
"icon": (await self.assets_service.material(ascension_material.id - 2).icon()).as_uri(),
"name": MATERIAL_DATA[str(ascension_material.id - 2)]["name"],
},
{
"num": 9,
"rarity": 4,
"icon": (await self.assets_service.material(str(ascension_material.id - 1)).icon()).as_uri(),
"name": MATERIAL_DATA[str(ascension_material.id - 1)]["name"],
},
{
"num": 6,
"rarity": 5,
"icon": (await ascension_material.icon()).as_uri(),
"name": MATERIAL_DATA[str(ascension_material.id)]["name"],
},
{
"num": 168,
"rarity": MATERIAL_DATA[str(local_material.id)]["rank"],
"icon": (await local_material.icon()).as_uri(),
"name": MATERIAL_DATA[str(local_material.id)]["name"],
},
{
"num": 18,
"rarity": MATERIAL_DATA[str(enemy_material.id)]["rank"],
"icon": (await self.assets_service.material(enemy_material.id).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id)]["name"],
},
{
"num": 30,
"rarity": MATERIAL_DATA[str(enemy_material.id + 1)]["rank"],
"icon": (await self.assets_service.material(enemy_material.id + 1).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id + 1)]["name"],
},
{
"num": 36,
"rarity": MATERIAL_DATA[str(enemy_material.id + 2)]["rank"],
"icon": (await self.assets_service.material(str(enemy_material.id + 2)).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id + 2)]["name"],
},
]
talent_book = self.assets_service.material(f"{data[character_name]['talent'][0]}」的教导")
weekly_talent_material = self.assets_service.material(data[character_name]["talent"][1])
talent_materials = [
{
"num": 9,
"rarity": MATERIAL_DATA[str(talent_book.id)]["rank"],
"icon": (await self.assets_service.material(talent_book.id).icon()).as_uri(),
"name": MATERIAL_DATA[str(talent_book.id)]["name"],
},
{
"num": 63,
"rarity": MATERIAL_DATA[str(talent_book.id + 1)]["rank"],
"icon": (await self.assets_service.material(talent_book.id + 1).icon()).as_uri(),
"name": MATERIAL_DATA[str(talent_book.id + 1)]["name"],
},
{
"num": 114,
"rarity": MATERIAL_DATA[str(talent_book.id + 2)]["rank"],
"icon": (await self.assets_service.material(str(talent_book.id + 2)).icon()).as_uri(),
"name": MATERIAL_DATA[str(talent_book.id + 2)]["name"],
},
{
"num": 18,
"rarity": MATERIAL_DATA[str(enemy_material.id)]["rank"],
"icon": (await self.assets_service.material(enemy_material.id).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id)]["name"],
},
{
"num": 66,
"rarity": MATERIAL_DATA[str(enemy_material.id + 1)]["rank"],
"icon": (await self.assets_service.material(enemy_material.id + 1).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id + 1)]["name"],
},
{
"num": 93,
"rarity": MATERIAL_DATA[str(enemy_material.id + 2)]["rank"],
"icon": (await self.assets_service.material(str(enemy_material.id + 2)).icon()).as_uri(),
"name": MATERIAL_DATA[str(enemy_material.id + 2)]["name"],
},
{
"num": 3,
"rarity": 5,
"icon": (await self.assets_service.material(104319).icon()).as_uri(),
"name": "智识之冕",
},
{
"num": 18,
"rarity": MATERIAL_DATA[str(weekly_talent_material.id)]["rank"],
"icon": (await self.assets_service.material(weekly_talent_material.id).icon()).as_uri(),
"name": MATERIAL_DATA[str(weekly_talent_material.id)]["name"],
},
]
return {
"character": {
"element": character.enka.element.name,
"image": character.enka.images.banner.url,
"name": character_name,
"association": (await Character.get_by_name(character_name)).association.name,
},
"level_up_materials": level_up_materials,
"talent_materials": talent_materials,
"talent_level": talent_level,
"talent_amount": TalentMaterials(list(map(int, talent_level.split("/")))).cal_materials(),
}
async def render(self, character_name: str, talent_amount: str):
if not self.roles_material:
await self._refresh()
data = await self._parse_material(self.roles_material, character_name, talent_amount)
if not data:
return
return await self.template_service.render(
"genshin/material/roles_material.jinja2",
data,
{"width": 960, "height": 1460},
full_page=True,
ttl=7 * 24 * 60 * 60,
)
@staticmethod
def _is_valid(string: str):
"""
判断字符串是否符合`8/9/10`的格式并保证每个数字都在[110]
"""
return bool(
re.match(r"^\d+/\d+/\d+$", string)
and all(1 <= int(num) <= 10 for num in string.split("/"))
and string != "1/1/1"
and string != "10/10/10"
)
@handler(CommandHandler, command="material", block=False)
@handler(MessageHandler, filters=filters.Regex("^角色培养素材查询(.*)"), block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
args = self.get_args(context)
if len(args) >= 1:
character_name = args[0]
material_count = "8/8/8"
if len(args) >= 2 and self._is_valid(args[1]):
material_count = args[1]
else:
reply_message = await message.reply_text(
"请回复你要查询的培养素材的角色名", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
character_name = roleToName(character_name)
self.log_user(update, logger.info, "查询角色培养素材命令请求 || 参数 %s", character_name)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
result = await self.render(character_name, material_count)
if not result:
reply_message = await message.reply_text(
f"没有找到 {character_name} 的培养素材", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
await result.reply_photo(message)

View File

@ -1,29 +0,0 @@
from plugins.genshin.model.base import *
from plugins.genshin.model.gcsim import *
__all__ = [
"Digit",
"DigitType",
"Character",
"CharacterInfo",
"CharacterStats",
"Weapon",
"WeaponInfo",
"WeaponType",
"Set",
"Artifact",
"ArtifactAttribute",
"ArtifactAttributeType",
"ArtifactPosition",
"GCSimCharacter",
"GCSimSet",
"GCSimSetInfo",
"GCSimWeapon",
"GCSimWeaponInfo",
"GCSimCharacterStats",
"GCSimCharacterInfo",
"GCSimTarget",
"GCSimEnergySettings",
"GCSim",
]

View File

@ -1,198 +0,0 @@
from decimal import Decimal
from enum import Enum
from typing import Optional, List, NewType
from pydantic import BaseModel, Field, validator
# TODO: 考虑自动生成Enum
Character = NewType("Character", str)
Weapon = NewType("Weapon", str)
Set = NewType("Set", str)
class DigitType(Enum):
NUMERIC = "numeric"
PERCENT = "percent"
class Digit(BaseModel):
type: DigitType
value: Decimal
class WeaponType(Enum):
BOW = "bow"
CLAYMORE = "claymore"
CATALYST = "catalyst"
POLEARM = "polearm"
SWORD = "sword"
class ArtifactPosition(Enum):
FLOWER = "flower"
PLUME = "plume"
SANDS = "sands"
GOBLET = "goblet"
CIRCLET = "circlet"
class ArtifactAttributeType(Enum):
HP = "hp"
ATK = "atk"
DEF = "def"
HP_PERCENT = "hp_percent"
ATK_PERCENT = "atk_percent"
DEF_PERCENT = "def_percent"
ELEMENTAL_MASTERY = "elemental_mastery"
ENERGY_RECHARGE = "energy_recharge"
CRIT_RATE = "crit_rate"
CRIT_DMG = "crit_dmg"
HEALING_BONUS = "healing_bonus"
PYRO_DMG_BONUS = "pyro_dmg_bonus"
HYDRO_DMG_BONUS = "hydro_dmg_bonus"
DENDRO_DMG_BONUS = "dendro_dmg_bonus"
ELECTRO_DMG_BONUS = "electro_dmg_bonus"
ANEMO_DMG_BONUS = "anemo_dmg_bonus"
CRYO_DMG_BONUS = "cryo_dmg_bonus"
GEO_DMG_BONUS = "geo_dmg_bonus"
PHYSICAL_DMG_BONUS = "physical_dmg_bonus"
class ArtifactAttribute(BaseModel):
type: ArtifactAttributeType
digit: Digit
class WeaponInfo(BaseModel):
id: int = 0
weapon: Weapon = ""
type: WeaponType
level: int = 0
max_level: int = 0
refinement: int = 0
ascension: int = 0
@validator("max_level")
def validate_max_level(cls, v, values):
if v == 0:
return values["level"]
if v < values["level"]:
raise ValueError("max_level must be greater than or equal to level")
return v
@validator("refinement")
def validate_refinement(cls, v):
if v < 0 or v > 5:
raise ValueError("refinement must be between 1 and 5")
return v
class Artifact(BaseModel):
id: int = 0
set: Set = ""
position: ArtifactPosition
level: int = 0
rarity: int = 0
main_attribute: ArtifactAttribute
sub_attributes: List[ArtifactAttribute] = []
@validator("level")
def validate_level(cls, v):
if v < 0 or v > 20:
raise ValueError("level must be between 0 and 20")
return v
@validator("rarity")
def validate_rarity(cls, v):
if v < 0 or v > 5:
raise ValueError("rarity must be between 0 and 5")
return v
@validator("sub_attributes")
def validate_sub_attributes(cls, v):
if len(v) > 4:
raise ValueError("sub_attributes must not be greater than 4")
return v
class CharacterStats(BaseModel):
BASE_HP: Digit = Digit(type=DigitType.NUMERIC, value=Decimal(0))
HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_HP")
HP_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HP_PERCENT")
BASE_ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_BASE_ATTACK")
ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_ATTACK")
ATTACK_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ATTACK_PERCENT")
BASE_DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_BASE_DEFENSE")
DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_DEFENSE")
DEFENSE_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_DEFENSE_PERCENT")
ELEMENTAL_MASTERY: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_ELEMENT_MASTERY"
)
CRIT_RATE: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CRITICAL")
CRIT_DMG: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CRITICAL_HURT")
HEALING_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HEAL_ADD")
INCOMING_HEALING_BONUS: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HEALED_ADD"
)
ENERGY_RECHARGE: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CHARGE_EFFICIENCY"
)
CD_REDUCTION: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_SKILL_CD_MINUS_RATIO"
)
SHIELD_STRENGTH: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_SHIELD_COST_MINUS_RATIO"
)
PYRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_FIRE_ADD_HURT")
PYRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_FIRE_SUB_HURT")
HYDRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WATER_ADD_HURT")
HYDRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WATER_SUB_HURT")
DENDRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_GRASS_ADD_HURT")
DENDRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_GRASS_SUB_HURT")
ELECTRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ELEC_ADD_HURT")
ELECTRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ELEC_SUB_HURT")
ANEMO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WIND_ADD_HURT")
ANEMO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WIND_SUB_HURT")
CRYO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ICE_ADD_HURT")
CRYO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ICE_SUB_HURT")
GEO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ROCK_ADD_HURT")
GEO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ROCK_SUB_HURT")
PHYSICAL_DMG_BONUS: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_PHYSICAL_SUB_HURT"
)
PHYSICAL_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_PHYSICAL_ADD_HURT")
CURRENT_HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_HP")
MAX_HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_MAX_HP")
CURRENT_ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_ATTACK")
CURRENT_DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_DEFENSE")
class CharacterInfo(BaseModel):
id: int = 0
character: Character = ""
weapon_info: Optional[WeaponInfo] = None
artifacts: List[Artifact] = []
level: int = 0
max_level: int = 0
constellation: int = 0
ascension: int = 0
skills: List[int] = []
rarity: int = 0
stats: CharacterStats = CharacterStats()
@validator("max_level")
def validate_max_level(cls, v, values):
if v == 0:
return values["level"]
if v < values["level"]:
raise ValueError("max_level must be greater than or equal to level")
return v
@validator("skills")
def validate_skills(cls, v):
if len(v) > 3:
raise ValueError("skills must not be greater than 3")
return v

View File

@ -1,197 +0,0 @@
from decimal import Decimal
from enkanetwork import (
CharacterInfo as EnkaCharacterInfo,
CharacterStats as EnkaCharacterStats,
StatsPercentage,
Equipments,
EquipmentsType,
EquipType,
EquipmentsStats,
DigitType as EnkaDigitType,
)
from plugins.genshin.model import (
CharacterInfo,
Digit,
DigitType,
CharacterStats,
WeaponInfo,
WeaponType,
Artifact,
ArtifactPosition,
ArtifactAttribute,
ArtifactAttributeType,
)
from plugins.genshin.model.metadata import Metadata
metadata = Metadata()
class EnkaConverter:
@classmethod
def to_weapon_type(cls, type_str: str) -> WeaponType:
if type_str == "WEAPON_BOW":
return WeaponType.BOW
if type_str == "WEAPON_CATALYST":
return WeaponType.CATALYST
if type_str == "WEAPON_CLAYMORE":
return WeaponType.CLAYMORE
if type_str == "WEAPON_POLE":
return WeaponType.POLEARM
if type_str == "WEAPON_SWORD_ONE_HAND":
return WeaponType.SWORD
if type_str == "单手剑":
return WeaponType.SWORD
raise ValueError(f"Unknown weapon type: {type_str}")
@classmethod
def to_weapon_info(cls, equipment: Equipments) -> WeaponInfo:
if equipment.type != EquipmentsType.WEAPON:
raise ValueError(f"Not weapon equipment type: {equipment.type}")
weapon_data = metadata.weapon_metadata.get(str(equipment.id))
if not weapon_data:
raise ValueError(f"Unknown weapon id: {equipment.id}")
return WeaponInfo(
id=equipment.id,
weapon=weapon_data["route"],
type=cls.to_weapon_type(weapon_data["type"]),
level=equipment.level,
max_level=equipment.max_level,
refinement=equipment.refinement,
ascension=equipment.ascension,
)
@classmethod
def to_artifact_attribute_type(cls, prop_id: str) -> ArtifactAttributeType: # skipcq: PY-R1000
if prop_id == "FIGHT_PROP_HP":
return ArtifactAttributeType.HP
if prop_id == "FIGHT_PROP_ATTACK":
return ArtifactAttributeType.ATK
if prop_id == "FIGHT_PROP_DEFENSE":
return ArtifactAttributeType.DEF
if prop_id == "FIGHT_PROP_HP_PERCENT":
return ArtifactAttributeType.HP_PERCENT
if prop_id == "FIGHT_PROP_ATTACK_PERCENT":
return ArtifactAttributeType.ATK_PERCENT
if prop_id == "FIGHT_PROP_DEFENSE_PERCENT":
return ArtifactAttributeType.DEF_PERCENT
if prop_id == "FIGHT_PROP_ELEMENT_MASTERY":
return ArtifactAttributeType.ELEMENTAL_MASTERY
if prop_id == "FIGHT_PROP_CHARGE_EFFICIENCY":
return ArtifactAttributeType.ENERGY_RECHARGE
if prop_id == "FIGHT_PROP_CRITICAL":
return ArtifactAttributeType.CRIT_RATE
if prop_id == "FIGHT_PROP_CRITICAL_HURT":
return ArtifactAttributeType.CRIT_DMG
if prop_id == "FIGHT_PROP_HEAL_ADD":
return ArtifactAttributeType.HEALING_BONUS
if prop_id == "FIGHT_PROP_FIRE_ADD_HURT":
return ArtifactAttributeType.PYRO_DMG_BONUS
if prop_id == "FIGHT_PROP_WATER_ADD_HURT":
return ArtifactAttributeType.HYDRO_DMG_BONUS
if prop_id == "FIGHT_PROP_ELEC_ADD_HURT":
return ArtifactAttributeType.ELECTRO_DMG_BONUS
if prop_id == "FIGHT_PROP_ICE_ADD_HURT":
return ArtifactAttributeType.CRYO_DMG_BONUS
if prop_id == "FIGHT_PROP_WIND_ADD_HURT":
return ArtifactAttributeType.ANEMO_DMG_BONUS
if prop_id == "FIGHT_PROP_ROCK_ADD_HURT":
return ArtifactAttributeType.GEO_DMG_BONUS
if prop_id == "FIGHT_PROP_GRASS_ADD_HURT":
return ArtifactAttributeType.DENDRO_DMG_BONUS
if prop_id == "FIGHT_PROP_PHYSICAL_ADD_HURT":
return ArtifactAttributeType.PHYSICAL_DMG_BONUS
raise ValueError(f"Unknown artifact attribute type: {prop_id}")
@classmethod
def to_artifact_attribute(cls, equip_stat: EquipmentsStats) -> ArtifactAttribute:
return ArtifactAttribute(
type=cls.to_artifact_attribute_type(equip_stat.prop_id),
digit=Digit(
value=Decimal(equip_stat.value),
type=DigitType.PERCENT if equip_stat.type == EnkaDigitType.PERCENT else DigitType.NUMERIC,
),
)
@classmethod
def to_artifact_position(cls, equip_type: EquipType) -> ArtifactPosition:
if equip_type == EquipType.Flower:
return ArtifactPosition.FLOWER
if equip_type == EquipType.Feather:
return ArtifactPosition.PLUME
if equip_type == EquipType.Sands:
return ArtifactPosition.SANDS
if equip_type == EquipType.Goblet:
return ArtifactPosition.GOBLET
if equip_type == EquipType.Circlet:
return ArtifactPosition.CIRCLET
raise ValueError(f"Unknown artifact position: {equip_type}")
@classmethod
def to_artifact(cls, equipment: Equipments) -> Artifact:
if equipment.type != EquipmentsType.ARTIFACT:
raise ValueError(f"Not artifact equipment type: {equipment.type}")
artifact_data = next(
(
data
for data in metadata.artifacts_metadata.values()
if data["name"] == equipment.detail.artifact_name_set
),
None,
)
if not artifact_data:
raise ValueError(f"Unknown artifact: {equipment}")
return Artifact(
id=artifact_data["id"],
set=artifact_data["route"],
position=cls.to_artifact_position(equipment.detail.artifact_type),
level=equipment.level,
rarity=equipment.detail.rarity,
main_attribute=cls.to_artifact_attribute(equipment.detail.mainstats),
sub_attributes=[cls.to_artifact_attribute(stat) for stat in equipment.detail.substats],
)
@classmethod
def to_character_stats(cls, character_stats: EnkaCharacterStats) -> CharacterStats:
return CharacterStats(
**{
stat: Digit(
value=Decimal(value.value),
type=DigitType.PERCENT if isinstance(value, StatsPercentage) else DigitType.NUMERIC,
)
for stat, value in character_stats._iter() # pylint: disable=W0212
}
)
@classmethod
def to_character(cls, character_info: EnkaCharacterInfo) -> str:
character_id = str(character_info.id)
if character_id in ("10000005", "10000007"):
character_id += f"-{character_info.element.name.lower()}"
character_data = metadata.characters_metadata.get(character_id)
if not character_data:
raise ValueError(f"Unknown character: {character_info.name}\n{character_info}")
return character_data["route"]
@classmethod
def to_character_info(cls, character_info: EnkaCharacterInfo) -> CharacterInfo:
weapon_equip = next((equip for equip in character_info.equipments if equip.type == EquipmentsType.WEAPON), None)
artifacts_equip = [equip for equip in character_info.equipments if equip.type == EquipmentsType.ARTIFACT]
return CharacterInfo(
id=character_info.id,
character=cls.to_character(character_info),
rarity=character_info.rarity,
weapon_info=cls.to_weapon_info(weapon_equip) if weapon_equip else None,
artifacts=[cls.to_artifact(equip) for equip in artifacts_equip],
level=character_info.level,
max_level=character_info.max_level,
ascension=character_info.ascension,
constellation=character_info.constellations_unlocked,
skills=[skill.level for skill in character_info.skills],
stats=cls.to_character_stats(character_info.stats),
)

View File

@ -1,405 +0,0 @@
import re
from collections import Counter
from decimal import Decimal
from functools import lru_cache
from typing import List, Optional, Tuple, Dict
from gcsim_pypi.aliases import CHARACTER_ALIASES, WEAPON_ALIASES, ARTIFACT_ALIASES
from pydantic import ValidationError
from plugins.genshin.model import (
Set,
Weapon,
DigitType,
WeaponInfo,
Artifact,
ArtifactAttributeType,
Character,
CharacterInfo,
GCSim,
GCSimTarget,
GCSimWeapon,
GCSimWeaponInfo,
GCSimSet,
GCSimSetInfo,
GCSimCharacter,
GCSimEnergySettings,
GCSimCharacterInfo,
GCSimCharacterStats,
)
from plugins.genshin.model.metadata import Metadata
from utils.log import logger
metadata = Metadata()
def remove_non_words(text: str) -> str:
return text.replace("'", "").replace('"', "").replace("-", "").replace(" ", "")
def from_character_gcsim_character(character: Character) -> GCSimCharacter:
if character == "Raiden Shogun":
return GCSimCharacter("raiden")
if character == "Yae Miko":
return GCSimCharacter("yaemiko")
if character == "Hu Tao":
return GCSimCharacter("hutao")
if character == "Yun Jin":
return GCSimCharacter("yunjin")
if character == "Kuki Shinobu":
return GCSimCharacter("kuki")
if "Traveler" in character:
s = character.split(" ")
traveler_name = "aether" if s[-1] == "Boy" else "lumine"
return GCSimCharacter(f"{traveler_name}{s[0].lower()}")
return GCSimCharacter(character.split(" ")[-1].lower())
GCSIM_CHARACTER_TO_CHARACTER: Dict[GCSimCharacter, Tuple[int, Character]] = {}
for char in metadata.characters_metadata.values():
GCSIM_CHARACTER_TO_CHARACTER[from_character_gcsim_character(char["route"])] = (char["id"], char["route"])
for alias, char in CHARACTER_ALIASES.items():
if alias not in GCSIM_CHARACTER_TO_CHARACTER:
if char in GCSIM_CHARACTER_TO_CHARACTER:
GCSIM_CHARACTER_TO_CHARACTER[alias] = GCSIM_CHARACTER_TO_CHARACTER[char]
elif alias.startswith("traveler") or alias.startswith("aether") or alias.startswith("lumine"):
continue
else:
logger.warning("Character alias %s not found in GCSIM", alias)
GCSIM_WEAPON_TO_WEAPON: Dict[GCSimWeapon, Tuple[int, Weapon]] = {}
for _weapon in metadata.weapon_metadata.values():
GCSIM_WEAPON_TO_WEAPON[remove_non_words(_weapon["route"].lower())] = (_weapon["id"], _weapon["route"])
for alias, _weapon in WEAPON_ALIASES.items():
if alias not in GCSIM_WEAPON_TO_WEAPON:
if _weapon in GCSIM_WEAPON_TO_WEAPON:
GCSIM_WEAPON_TO_WEAPON[alias] = GCSIM_WEAPON_TO_WEAPON[_weapon]
else:
logger.warning("Weapon alias %s not found in GCSIM", alias)
GCSIM_ARTIFACT_TO_ARTIFACT: Dict[GCSimSet, Tuple[int, Set]] = {}
for _artifact in metadata.artifacts_metadata.values():
GCSIM_ARTIFACT_TO_ARTIFACT[remove_non_words(_artifact["route"].lower())] = (_artifact["id"], _artifact["route"])
for alias, _artifact in ARTIFACT_ALIASES.items():
if alias not in GCSIM_ARTIFACT_TO_ARTIFACT:
if _artifact in GCSIM_ARTIFACT_TO_ARTIFACT:
GCSIM_ARTIFACT_TO_ARTIFACT[alias] = GCSIM_ARTIFACT_TO_ARTIFACT[_artifact]
else:
logger.warning("Artifact alias %s not found in GCSIM", alias)
class GCSimConverter:
literal_keys_numeric_values_regex = re.compile(
r"([\w_%]+)=(\d+ *, *\d+ *, *\d+|[\d*\.*\d+]+ *, *[\d*\.*\d+]+|\d+/\d+|\d*\.*\d+|\d+)"
)
@classmethod
def to_character(cls, character: GCSimCharacter) -> Tuple[int, Character]:
return GCSIM_CHARACTER_TO_CHARACTER[character]
@classmethod
def from_character(cls, character: Character) -> GCSimCharacter:
return from_character_gcsim_character(character)
@classmethod
def to_weapon(cls, weapon: GCSimWeapon) -> Tuple[int, Weapon]:
return GCSIM_WEAPON_TO_WEAPON[weapon]
@classmethod
def from_weapon(cls, weapon: Weapon) -> GCSimWeapon:
return GCSimWeapon(remove_non_words(weapon).lower())
@classmethod
def from_weapon_info(cls, weapon_info: Optional[WeaponInfo]) -> GCSimWeaponInfo:
if weapon_info is None:
return GCSimWeaponInfo(weapon=GCSimWeapon("dullblade"), refinement=1, level=1, max_level=20)
return GCSimWeaponInfo(
weapon=cls.from_weapon(weapon_info.weapon),
refinement=weapon_info.refinement,
level=weapon_info.level,
max_level=weapon_info.max_level,
)
@classmethod
def to_set(cls, set_name: GCSimSet) -> Tuple[int, Set]:
return GCSIM_ARTIFACT_TO_ARTIFACT[set_name]
@classmethod
def from_set(cls, set_name: Set) -> GCSimSet:
return GCSimSet(remove_non_words(set_name).lower())
@classmethod
def from_artifacts(cls, artifacts: List[Artifact]) -> List[GCSimSetInfo]:
c = Counter()
for art in artifacts:
c[cls.from_set(art.set)] += 1
return [GCSimSetInfo(set=set_name, count=count) for set_name, count in c.items()]
@classmethod
@lru_cache
def from_attribute_type(cls, attribute_type: ArtifactAttributeType) -> str: # skipcq: PY-R1000
if attribute_type == ArtifactAttributeType.HP:
return "HP"
if attribute_type == ArtifactAttributeType.HP_PERCENT:
return "HP_PERCENT"
if attribute_type == ArtifactAttributeType.ATK:
return "ATK"
if attribute_type == ArtifactAttributeType.ATK_PERCENT:
return "ATK_PERCENT"
if attribute_type == ArtifactAttributeType.DEF:
return "DEF"
if attribute_type == ArtifactAttributeType.DEF_PERCENT:
return "DEF_PERCENT"
if attribute_type == ArtifactAttributeType.ELEMENTAL_MASTERY:
return "EM"
if attribute_type == ArtifactAttributeType.ENERGY_RECHARGE:
return "ER"
if attribute_type == ArtifactAttributeType.CRIT_RATE:
return "CR"
if attribute_type == ArtifactAttributeType.CRIT_DMG:
return "CD"
if attribute_type == ArtifactAttributeType.HEALING_BONUS:
return "HEAL"
if attribute_type == ArtifactAttributeType.PYRO_DMG_BONUS:
return "PYRO_PERCENT"
if attribute_type == ArtifactAttributeType.HYDRO_DMG_BONUS:
return "HYDRO_PERCENT"
if attribute_type == ArtifactAttributeType.DENDRO_DMG_BONUS:
return "DENDRO_PERCENT"
if attribute_type == ArtifactAttributeType.ELECTRO_DMG_BONUS:
return "ELECTRO_PERCENT"
if attribute_type == ArtifactAttributeType.ANEMO_DMG_BONUS:
return "ANEMO_PERCENT"
if attribute_type == ArtifactAttributeType.CRYO_DMG_BONUS:
return "CRYO_PERCENT"
if attribute_type == ArtifactAttributeType.GEO_DMG_BONUS:
return "GEO_PERCENT"
if attribute_type == ArtifactAttributeType.PHYSICAL_DMG_BONUS:
return "PHYS_PERCENT"
raise ValueError(f"Unknown attribute type: {attribute_type}")
@classmethod
def from_artifacts_stats(cls, artifacts: List[Artifact]) -> GCSimCharacterStats:
gcsim_stats = GCSimCharacterStats()
for art in artifacts:
main_attr_name = cls.from_attribute_type(art.main_attribute.type)
setattr(
gcsim_stats,
main_attr_name,
getattr(gcsim_stats, main_attr_name)
+ (
Decimal(art.main_attribute.digit.value) / Decimal(100)
if art.main_attribute.digit.type == DigitType.PERCENT
else Decimal(art.main_attribute.digit.value)
),
)
for sub_attr in art.sub_attributes:
attr_name = cls.from_attribute_type(sub_attr.type)
setattr(
gcsim_stats,
attr_name,
getattr(gcsim_stats, attr_name)
+ (
Decimal(sub_attr.digit.value) / Decimal(100)
if sub_attr.digit.type == DigitType.PERCENT
else Decimal(sub_attr.digit.value)
),
)
return gcsim_stats
@classmethod
def from_character_info(cls, character: CharacterInfo) -> GCSimCharacterInfo:
return GCSimCharacterInfo(
character=cls.from_character(character.character),
level=character.level,
max_level=character.max_level,
constellation=character.constellation,
talent=character.skills,
weapon_info=cls.from_weapon_info(character.weapon_info),
set_info=cls.from_artifacts(character.artifacts),
# NOTE: Only stats from arifacts are needed
stats=cls.from_artifacts_stats(character.artifacts),
)
@classmethod
def merge_character_infos(cls, gcsim: GCSim, character_infos: List[CharacterInfo]) -> GCSim:
gcsim_characters = {ch.character: ch for ch in gcsim.characters}
for character_info in character_infos:
try:
gcsim_character = cls.from_character_info(character_info)
if gcsim_character.character in gcsim_characters:
gcsim_characters[gcsim_character.character] = gcsim_character
except ValidationError as e:
errors = e.errors()
if errors and errors[0].get("msg").startswith("Not supported"):
# Something is not supported, skip
continue
logger.warning("Failed to convert character info: %s", character_info)
gcsim.characters = list(gcsim_characters.values())
return gcsim
@classmethod
def prepend_scripts(cls, gcsim: GCSim, scripts: List[str]) -> GCSim:
gcsim.scripts = scripts + gcsim.scripts
return gcsim
@classmethod
def append_scripts(cls, gcsim: GCSim, scripts: List[str]) -> GCSim:
gcsim.scripts = gcsim.scripts + scripts
return gcsim
@classmethod
def from_gcsim_energy(cls, line: str) -> GCSimEnergySettings:
energy_settings = GCSimEnergySettings()
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "interval":
energy_settings.intervals = list(map(int, value.split(",")))
elif key == "amount":
energy_settings.amount = int(value)
else:
logger.warning("Unknown energy setting: %s=%s", key, value)
return energy_settings
@classmethod
def from_gcsim_target(cls, line: str) -> GCSimTarget:
target = GCSimTarget()
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "lvl":
target.level = int(value)
elif key == "hp":
target.hp = int(value)
elif key == "amount":
target.amount = int(value)
elif key == "resist":
target.resist = float(value)
elif key == "pos":
target.position = tuple(p for p in value.split(","))
elif key == "interval":
target.interval = list(map(int, value.split(",")))
elif key == "radius":
target.radius = float(value)
elif key == "particle_threshold":
target.particle_threshold = float(value)
elif key == "particle_drop_count":
target.particle_drop_count = float(value)
elif key in ("pyro", "hydro", "dendro", "electro", "anemo", "cryo", "geo", "physical"):
target.others[key] = float(value)
else:
logger.warning("Unknown target setting: %s=%s", key, value)
return target
@classmethod
def from_gcsim_char_line(cls, line: str, character: GCSimCharacterInfo) -> GCSimCharacterInfo:
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "lvl":
character.level, character.max_level = map(int, value.split("/"))
elif key == "cons":
character.constellation = int(value)
elif key == "talent":
character.talent = list(map(int, value.split(",")))
elif key == "start_hp":
character.start_hp = int(value)
elif key == "breakthrough":
character.params.append(f"{key}={value}")
else:
logger.warning("Unknown character setting: %s=%s", key, value)
return character
@classmethod
def from_gcsim_weapon_line(cls, line: str, weapon_info: GCSimWeaponInfo) -> GCSimWeaponInfo:
weapon_name = re.search(r"weapon= *\"(.*)\"", line).group(1)
if weapon_name not in WEAPON_ALIASES:
raise ValueError(f"Unknown weapon: {weapon_name}")
weapon_info.weapon = WEAPON_ALIASES[weapon_name]
for key, value in cls.literal_keys_numeric_values_regex.findall(line):
if key == "refine":
weapon_info.refinement = int(value)
elif key == "lvl":
weapon_info.level, weapon_info.max_level = map(int, value.split("/"))
elif key.startswith("stack"):
weapon_info.params.append(f"stacks={value}")
elif key in ("pickup_delay", "breakthrough"):
weapon_info.params.append(f"{key}={value}")
else:
logger.warning("Unknown weapon setting: %s=%s", key, value)
return weapon_info
@classmethod
def from_gcsim_set_line(cls, line: str) -> GCSimSetInfo:
gcsim_set = re.search(r"set= *\"(.*)\"", line).group(1)
if gcsim_set not in ARTIFACT_ALIASES:
raise ValueError(f"Unknown set: {gcsim_set}")
gcsim_set = ARTIFACT_ALIASES[gcsim_set]
set_info = GCSimSetInfo(set=gcsim_set)
for key, value in cls.literal_keys_numeric_values_regex.findall(line):
if key == "count":
set_info.count = int(value)
elif key.startswith("stack"):
set_info.params.append(f"stacks={value}")
else:
logger.warning("Unknown set info: %s=%s", key, value)
return set_info
@classmethod
def from_gcsim_stats_line(cls, line: str, stats: GCSimCharacterStats) -> GCSimCharacterStats:
matches = re.findall(r"(\w+%?)=(\d*\.*\d+)", line)
for stat, value in matches:
attr = stat.replace("%", "_percent").upper()
setattr(stats, attr, getattr(stats, attr) + Decimal(value))
return stats
@classmethod
def from_gcsim_script(cls, script: str) -> GCSim: # skipcq: PY-R1000
options = ""
characters = {}
character_aliases = {}
active_character = None
targets = []
energy_settings = GCSimEnergySettings()
script_lines = []
for line in script.strip().split("\n"):
line = line.split("#")[0].strip()
if not line or line.startswith("#"):
continue
if line.startswith("options"):
options = line.strip(";")
elif line.startswith("target"):
targets.append(cls.from_gcsim_target(line))
elif line.startswith("energy"):
energy_settings = cls.from_gcsim_energy(line)
elif line.startswith("active"):
active_character = line.strip(";").split(" ")[1]
elif m := re.match(r"(\w+) +(char|add weapon|add set|add stats)\W", line):
if m.group(1) not in CHARACTER_ALIASES:
raise ValueError(f"Unknown character: {m.group(1)}")
c = CHARACTER_ALIASES[m.group(1)]
if c not in characters:
characters[c] = GCSimCharacterInfo(character=c)
if m.group(1) != c:
character_aliases[m.group(1)] = c
if m.group(2) == "char":
characters[c] = cls.from_gcsim_char_line(line, characters[c])
elif m.group(2) == "add weapon":
characters[c].weapon_info = cls.from_gcsim_weapon_line(line, characters[c].weapon_info)
elif m.group(2) == "add set":
characters[c].set_info.append(cls.from_gcsim_set_line(line))
elif m.group(2) == "add stats":
characters[c].stats = cls.from_gcsim_stats_line(line, characters[c].stats)
else:
for key, value in character_aliases.items():
line = line.replace(f"{key} ", f"{value} ")
line = line.replace(f".{key}.", f".{value}.")
script_lines.append(line)
return GCSim(
options=options,
characters=list(characters.values()),
targets=targets,
energy_settings=energy_settings,
active_character=active_character,
script_lines=script_lines,
)

View File

@ -1,237 +0,0 @@
from decimal import Decimal
from typing import Any, NewType, List, Optional, Tuple, Dict
from gcsim_pypi.aliases import ARTIFACT_ALIASES, CHARACTER_ALIASES, WEAPON_ALIASES
from gcsim_pypi.availability import AVAILABLE_ARTIFACTS, AVAILABLE_CHARACTERS, AVAILABLE_WEAPONS
from pydantic import BaseModel, validator
GCSimCharacter = NewType("GCSimCharacter", str)
GCSimWeapon = NewType("GCSimWeapon", str)
GCSimSet = NewType("GCSimSet", str)
class GCSimWeaponInfo(BaseModel):
weapon: GCSimWeapon = "dullblade"
refinement: int = 1
level: int = 1
max_level: int = 20
params: List[str] = []
@validator("weapon")
def validate_weapon(cls, v):
if v not in AVAILABLE_WEAPONS or v not in WEAPON_ALIASES:
raise ValueError(f"Not supported weapon: {v}")
return WEAPON_ALIASES[v]
class GCSimSetInfo(BaseModel):
set: GCSimSet
count: int = 2
params: List[str] = []
@validator("set")
def validate_set(cls, v):
if v not in AVAILABLE_ARTIFACTS or v not in ARTIFACT_ALIASES:
raise ValueError(f"Not supported set: {v}")
return ARTIFACT_ALIASES[v]
class GCSimCharacterStats(BaseModel):
HP: Decimal = Decimal(0)
HP_PERCENT: Decimal = Decimal(0)
ATK: Decimal = Decimal(0)
ATK_PERCENT: Decimal = Decimal(0)
DEF: Decimal = Decimal(0)
DEF_PERCENT: Decimal = Decimal(0)
EM: Decimal = Decimal(0)
ER: Decimal = Decimal(0)
CR: Decimal = Decimal(0)
CD: Decimal = Decimal(0)
HEAL: Decimal = Decimal(0)
PYRO_PERCENT: Decimal = Decimal(0)
HYDRO_PERCENT: Decimal = Decimal(0)
DENDRO_PERCENT: Decimal = Decimal(0)
ELECTRO_PERCENT: Decimal = Decimal(0)
ANEMO_PERCENT: Decimal = Decimal(0)
CRYO_PERCENT: Decimal = Decimal(0)
GEO_PERCENT: Decimal = Decimal(0)
PHYS_PERCENT: Decimal = Decimal(0)
class GCSimCharacterInfo(BaseModel):
character: GCSimCharacter
level: int = 1
max_level: int = 20
constellation: int = 0
talent: List[int] = [1, 1, 1]
start_hp: Optional[int] = None
weapon_info: GCSimWeaponInfo = GCSimWeaponInfo()
set_info: List[GCSimSetInfo] = []
stats: GCSimCharacterStats = GCSimCharacterStats()
params: List[str] = []
@validator("character")
def validate_character(cls, v):
if v not in AVAILABLE_CHARACTERS or v not in CHARACTER_ALIASES:
raise ValueError(f"Not supported character: {v}")
return CHARACTER_ALIASES[v]
@property
def char(self) -> str:
return self.character
@property
def char_line(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
"char",
f"lvl={self.level}/{self.max_level}",
f"cons={self.constellation}",
f"start_hp={self.start_hp}" if self.start_hp is not None else "",
f"talent={','.join(str(t) for t in self.talent)}",
f"+params=[{','.join(self.params)}] " if self.params else "",
],
)
)
+ ";"
)
@property
def weapon_line(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
f'add weapon="{self.weapon_info.weapon}"',
f"refine={self.weapon_info.refinement}",
f"lvl={self.weapon_info.level}/{self.weapon_info.max_level}",
f"+params=[{','.join(self.weapon_info.params)}] " if self.weapon_info.params else "",
],
)
)
+ ";"
)
@property
def set_line(self) -> str:
return "\n".join(
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
f'add set="{set_info.set}"',
f"count={4 if set_info.count >= 4 else 2}",
f"+params=[{','.join(set_info.params)}] " if set_info.params else "",
],
)
)
+ ";"
for set_info in self.set_info
# NOTE: 祭*系列似乎并不支持
if set_info.count > 1
)
@property
def stats_line(self) -> str:
if all(value == 0 for _, value in self.stats):
return ""
return (
f"{self.char} add stats "
+ " ".join(
[
(
f"{stat.replace('_PERCENT', '%').lower()}={value:.4f}"
if stat.endswith("_PERCENT") or stat in {"CR", "CD", "ER"}
else f"{stat.lower()}={value:.2f}"
)
for stat, value in iter(self.stats)
if value > 0
]
)
+ ";"
)
def __str__(self) -> str:
return "\n".join([self.char_line, self.weapon_line, self.set_line, self.stats_line])
class GCSimTarget(BaseModel):
level: int = 100
resist: float = 0.1
position: Tuple[str, str] = ("0", "0")
interval: List[int] = []
radius: Optional[float] = None
hp: Optional[int] = None
amount: Optional[int] = None
particle_threshold: Optional[float] = None
particle_drop_count: Optional[float] = None
others: Dict[str, Any] = {}
def __str__(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"target lvl={self.level} resist={self.resist} ",
f"pos={','.join(self.position)}",
f"radius={self.radius}" if self.radius is not None else "",
f"hp={self.hp}" if self.hp is not None else "",
f"amount={self.amount}" if self.amount is not None else "",
f"interval={','.join(str(i) for i in self.interval)}" if self.interval else "",
f"particle_threshold={self.particle_threshold}" if self.particle_threshold is not None else "",
(
f"particle_drop_count={self.particle_drop_count}"
if self.particle_drop_count is not None
else ""
),
" ".join([f"{k}={v}" for k, v in self.others.items()]),
],
)
)
+ ";"
)
class GCSimEnergySettings(BaseModel):
intervals: List[int] = [480, 720]
amount: int = 1
def __str__(self) -> str:
return f"energy every interval={','.join(str(i) for i in self.intervals)} amount={self.amount};"
class GCSim(BaseModel):
options: Optional[str] = None
characters: List[GCSimCharacterInfo] = []
targets: List[GCSimTarget] = [GCSimTarget()]
energy_settings: Optional[GCSimEnergySettings] = None
# TODO: Do we even want this?
hurt_settings: Optional[str] = None
active_character: Optional[GCSimCharacter] = None
script_lines: List[str] = []
def __str__(self) -> str:
line = ""
if self.options:
line += f"{self.options};\n"
line += "\n".join([str(c) for c in self.characters])
line += "\n"
line += "\n".join([str(t) for t in self.targets])
line += "\n"
if self.energy_settings:
line += f"{self.energy_settings}\n"
if self.active_character:
line += f"active {self.active_character};\n"
else:
line += f"active {self.characters[0].char};\n"
line += "\n".join(self.script_lines)
line += "\n"
return line

View File

@ -1,31 +0,0 @@
from typing import Dict, Any
from utils.const import PROJECT_ROOT
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
METADATA_PATH = PROJECT_ROOT.joinpath("metadata").joinpath("data")
class Metadata:
_instance: "Metadata" = None
weapon_metadata: Dict[str, Any] = {}
artifacts_metadata: Dict[str, Any] = {}
characters_metadata: Dict[str, Any] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.reload_assets()
return cls._instance
def reload_assets(self) -> None:
self.__load_assets_data()
def __load_assets_data(self) -> None:
self.weapon_metadata = jsonlib.loads(METADATA_PATH.joinpath("weapon.json").read_text(encoding="utf-8"))
self.artifacts_metadata = jsonlib.loads(METADATA_PATH.joinpath("reliquary.json").read_text(encoding="utf-8"))
self.characters_metadata = jsonlib.loads(METADATA_PATH.joinpath("avatar.json").read_text(encoding="utf-8"))

View File

@ -1,244 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from simnet import GenshinClient, Region
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction
from telegram.ext import CommandHandler, MessageHandler, filters, ConversationHandler
from telegram.helpers import create_deep_linked_url
from core.basemodel import RegionEnum
from core.config import config
from core.plugin import Plugin, handler, conversation
from core.services.cookies import CookiesService
from core.services.players.services import PlayersService
from core.services.template.services import TemplateService
from modules.gacha_log.helpers import from_url_get_authkey
from modules.pay_log.error import PayLogNotFound, PayLogAccountNotFound, PayLogInvalidAuthkey, PayLogAuthkeyTimeout
from modules.pay_log.log import PayLog
from modules.pay_log.migrate import PayLogMigrate
from plugins.tools.genshin import PlayerNotFoundError, CookiesNotFoundError
from plugins.tools.player_info import PlayerInfoSystem
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update, User
from telegram.ext import ContextTypes
from gram_core.services.players.models import Player
INPUT_URL, CONFIRM_DELETE = range(10100, 10102)
WAITING = f"{config.notice.bot_name}正在从服务器获取数据,请稍后"
PAYLOG_NOT_FOUND = f"{config.notice.bot_name}没有找到你的充值记录,快来私聊{config.notice.bot_name}导入吧~"
class PayLogPlugin(Plugin.Conversation):
"""充值记录导入/导出/分析"""
def __init__(
self,
template_service: TemplateService,
players_service: PlayersService,
cookie_service: CookiesService,
player_info: PlayerInfoSystem,
):
self.template_service = template_service
self.players_service = players_service
self.cookie_service = cookie_service
self.pay_log = PayLog()
self.player_info = player_info
async def get_player_id(self, uid: int) -> int:
"""获取绑定的游戏ID"""
logger.debug("尝试获取已绑定的原神账号")
player = await self.players_service.get_player(uid)
if player is None:
raise PlayerNotFoundError(uid)
return player.player_id
async def _refresh_user_data(self, user: "User", authkey: str = None) -> str:
"""刷新用户数据
:param user: 用户
:param authkey: 认证密钥
:return: 返回信息
"""
try:
player_id = await self.get_player_id(user.id)
new_num = await self.pay_log.get_log_data(user.id, player_id, authkey)
return "更新完成,本次没有新增数据" if new_num == 0 else f"更新完成,本次共新增{new_num}条充值记录"
except PayLogNotFound:
return f"{config.notice.bot_name}没有找到你的充值记录,快去充值吧~"
except PayLogAccountNotFound:
return "导入失败,可能文件包含的祈愿记录所属 uid 与你当前绑定的 uid 不同"
except PayLogInvalidAuthkey:
return "更新数据失败authkey 无效"
except PayLogAuthkeyTimeout:
return "更新数据失败authkey 已经过期"
except PlayerNotFoundError:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
return config.notice.user_not_found
@conversation.entry_point
@handler.command(command="pay_log_import", filters=filters.ChatType.PRIVATE, block=False)
@handler.message(filters=filters.Regex("^导入充值记录$") & filters.ChatType.PRIVATE, block=False)
@handler.command(command="start", filters=filters.Regex("pay_log_import$"), block=False)
async def command_start(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 导入充值记录命令请求", user.full_name, user.id)
player_info = await self.players_service.get_player(user.id, region=RegionEnum.HYPERION)
if player_info is not None:
cookies = await self.cookie_service.get(user.id, account_id=player_info.account_id)
if cookies is not None:
if "stoken" in cookies.data:
if stuid := next(
(value for key, value in cookies.data.items() if key in ["ltuid", "login_uid"]), None
):
cookies.data["stuid"] = stuid
async with GenshinClient(
cookies=cookies.data, region=Region.CHINESE, lang="zh-cn", player_id=player_info.player_id
) as client:
authkey = await client.get_authkey_by_stoken("csc")
else:
await message.reply_text("该功能需要绑定 stoken 才能使用")
return ConversationHandler.END
else:
raise CookiesNotFoundError(user.id)
else:
raise CookiesNotFoundError(user.id)
reply = await message.reply_text(WAITING)
await message.reply_chat_action(ChatAction.TYPING)
data = await self._refresh_user_data(user, authkey=authkey)
await reply.edit_text(data)
return ConversationHandler.END
@conversation.state(state=INPUT_URL)
@handler.message(filters=~filters.COMMAND, block=False)
async def import_data_from_message(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
if message.document:
await message.reply_text("呜呜呜~本次导入不支持文件导入,请尝试获取连接")
return INPUT_URL
if not message.text:
await message.reply_text("呜呜呜~输入错误,请尝试重新获取连接")
return INPUT_URL
authkey = from_url_get_authkey(message.text)
reply = await message.reply_text(WAITING)
await message.reply_chat_action(ChatAction.TYPING)
text = await self._refresh_user_data(user, authkey=authkey)
await reply.edit_text(text)
return ConversationHandler.END
@conversation.entry_point
@handler(CommandHandler, command="pay_log_delete", filters=filters.ChatType.PRIVATE, block=False)
@handler(MessageHandler, filters=filters.Regex("^删除充值记录$") & filters.ChatType.PRIVATE, block=False)
async def command_start_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 删除充值记录命令请求", user.full_name, user.id)
player_info = await self.players_service.get_player(user.id)
if player_info is None:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
await message.reply_text(config.notice.user_not_found)
return ConversationHandler.END
_, status = await self.pay_log.load_history_info(str(user.id), str(player_info.player_id), only_status=True)
if not status:
await message.reply_text("你还没有导入充值记录哦~")
return ConversationHandler.END
context.chat_data["uid"] = player_info.player_id
await message.reply_text(
"你确定要删除充值记录吗?(此项操作无法恢复),如果确定请发送 ”确定“,发送其他内容取消"
)
return CONFIRM_DELETE
@conversation.state(state=CONFIRM_DELETE)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def command_confirm_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
if message.text == "确定":
status = await self.pay_log.remove_history_info(str(user.id), str(context.chat_data["uid"]))
await message.reply_text("充值记录已删除" if status else "充值记录删除失败")
return ConversationHandler.END
await message.reply_text("已取消")
return ConversationHandler.END
@handler(CommandHandler, command="pay_log_force_delete", block=False, admin=True)
async def command_pay_log_force_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
message = update.effective_message
args = self.get_args(context)
if not args:
await message.reply_text("请指定用户ID")
return
try:
cid = int(args[0])
if cid < 0:
raise ValueError("Invalid cid")
player_info = await self.players_service.get_player(cid)
if player_info is None:
await message.reply_text("该用户暂未绑定账号")
return
_, status = await self.pay_log.load_history_info(str(cid), str(player_info.player_id), only_status=True)
if not status:
await message.reply_text("该用户还没有导入充值记录")
return
status = await self.pay_log.remove_history_info(str(cid), str(player_info.player_id))
await message.reply_text("充值记录已强制删除" if status else "充值记录删除失败")
except PayLogNotFound:
await message.reply_text("该用户还没有导入充值记录")
except (ValueError, IndexError):
await message.reply_text("用户ID 不合法")
@handler(CommandHandler, command="pay_log_export", filters=filters.ChatType.PRIVATE, block=False)
@handler(MessageHandler, filters=filters.Regex("^导出充值记录$") & filters.ChatType.PRIVATE, block=False)
async def command_start_export(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 导出充值记录命令请求", user.full_name, user.id)
try:
await message.reply_chat_action(ChatAction.TYPING)
player_id = await self.get_player_id(user.id)
path = self.pay_log.get_file_path(str(user.id), str(player_id))
if not path.exists():
raise PayLogNotFound
await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT)
await message.reply_document(document=open(path, "rb+"), caption="充值记录导出文件")
except PayLogNotFound:
buttons = [
[InlineKeyboardButton("点我导入", url=create_deep_linked_url(context.bot.username, "pay_log_import"))]
]
await message.reply_text(PAYLOG_NOT_FOUND, reply_markup=InlineKeyboardMarkup(buttons))
except PayLogAccountNotFound:
await message.reply_text("导出失败,可能文件包含的祈愿记录所属 uid 与你当前绑定的 uid 不同")
except PlayerNotFoundError:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
await message.reply_text(config.notice.user_not_found)
@handler(CommandHandler, command="pay_log", block=False)
@handler(MessageHandler, filters=filters.Regex("^充值记录$"), block=False)
async def command_start_analysis(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
message = update.effective_message
self.log_user(update, logger.info, "充值记录统计命令请求")
try:
await message.reply_chat_action(ChatAction.TYPING)
player_id = await self.get_player_id(user_id)
data = await self.pay_log.get_analysis(user_id, player_id)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
name_card = await self.player_info.get_name_card(player_id, user_id)
data["name_card"] = name_card
png_data = await self.template_service.render(
"genshin/pay_log/pay_log.jinja2", data, full_page=True, query_selector=".container"
)
await png_data.reply_photo(message)
except PayLogNotFound:
buttons = [
[InlineKeyboardButton("点我导入", url=create_deep_linked_url(context.bot.username, "pay_log_import"))]
]
await message.reply_text(PAYLOG_NOT_FOUND, reply_markup=InlineKeyboardMarkup(buttons))
@staticmethod
async def get_migrate_data(
old_user_id: int, new_user_id: int, old_players: List["Player"]
) -> Optional[PayLogMigrate]:
return await PayLogMigrate.create(old_user_id, new_user_id, old_players)

View File

@ -1,748 +0,0 @@
import copy
import math
from typing import Any, List, Tuple, Union, Optional, TYPE_CHECKING, Dict
from enkanetwork import (
DigitType,
EnkaNetworkResponse,
EnkaServerError,
Equipments,
EquipmentsType,
HTTPException,
Stats,
StatsPercentage,
VaildateUIDError,
EnkaServerMaintanance,
EnkaServerUnknown,
EnkaServerRateLimit,
EnkaPlayerNotFound,
TimedOut,
)
from pydantic import BaseModel
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction
from telegram.ext import filters
from core.config import config
from core.dependence.assets import DEFAULT_EnkaAssets, AssetsService
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.players import PlayersService
from core.services.template.services import TemplateService
from metadata.shortname import roleToName, idToName
from modules.apihelper.client.components.remote import Remote
from modules.gcsim.file import PlayerGCSimScripts
from modules.playercards.file import PlayerCardsFile
from modules.playercards.helpers import ArtifactStatsTheory
from plugins.tools.genshin import PlayerNotFoundError
from utils.enkanetwork import RedisCache, EnkaNetworkAPI
from utils.helpers import download_resource
from utils.log import logger
from utils.uid import mask_number
try:
from python_genshin_artifact import get_damage_analysis, get_transformative_damage
from python_genshin_artifact.enka.enka_parser import enka_parser
from python_genshin_artifact.error import JsonParseException, EnkaParseException
from python_genshin_artifact import CalculatorConfig, SkillInterface
GENSHIN_ARTIFACT_FUNCTION_AVAILABLE = True
except ImportError:
get_damage_analysis = None
get_transformative_damage = None
enka_parser = None
CalculatorConfig = None
SkillInterface = None
GENSHIN_ARTIFACT_FUNCTION_AVAILABLE = False
if TYPE_CHECKING:
from enkanetwork import CharacterInfo, EquipmentsStats
from telegram.ext import ContextTypes
from telegram import Update, Message
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
class PlayerCards(Plugin):
def __init__(
self,
player_service: PlayersService,
template_service: TemplateService,
assets_service: AssetsService,
redis: RedisDB,
):
self.player_service = player_service
self.client = EnkaNetworkAPI(lang="chs", user_agent=config.enka_network_api_agent, cache=False)
self.cache = RedisCache(redis.client, key="plugin:player_cards:enka_network", ex=60)
self.player_cards_file = PlayerCardsFile()
self.player_gcsim_scripts = PlayerGCSimScripts()
self.assets_service = assets_service
self.template_service = template_service
self.kitsune: Optional[str] = None
self.fight_prop_rule: Dict[str, Dict[str, float]] = {}
self.damage_config: Dict = {}
async def initialize(self):
await self._refresh()
async def _refresh(self):
self.fight_prop_rule = await Remote.get_fight_prop_rule_data()
self.damage_config = await Remote.get_damage_data()
async def _update_enka_data(self, uid) -> Union[EnkaNetworkResponse, str]:
try:
data = await self.cache.get(uid)
if data is not None:
return EnkaNetworkResponse.parse_obj(data)
user = await self.client.http.fetch_user_by_uid(uid)
data = user["content"].decode("utf-8", "surrogatepass") # type: ignore
data = jsonlib.loads(data)
data = await self.player_cards_file.merge_info(uid, data)
await self.cache.set(uid, data)
return EnkaNetworkResponse.parse_obj(data)
except TimedOut:
error = "Enka.Network 服务请求超时,请稍后重试"
except EnkaServerRateLimit:
error = "Enka.Network 已对此API进行速率限制请稍后重试"
except EnkaServerMaintanance:
error = "Enka.Network 正在维护请等待5-8小时或1天"
except EnkaServerError:
error = "Enka.Network 服务请求错误,请稍后重试"
except EnkaServerUnknown:
error = "Enka.Network 服务瞬间爆炸,请稍后重试"
except EnkaPlayerNotFound:
error = "UID 未找到,可能为服务器抽风,请稍后重试"
except VaildateUIDError:
error = "未找到玩家请检查您的UID/用户名"
except HTTPException:
error = "Enka.Network HTTP 服务请求错误,请稍后重试"
return error
async def _load_data_as_enka_response(self, uid) -> Optional[EnkaNetworkResponse]:
data = await self.player_cards_file.load_history_info(uid)
if data is None:
return None
return EnkaNetworkResponse.parse_obj(data)
async def _load_history(self, uid) -> Optional[Dict]:
return await self.player_cards_file.load_history_info(uid)
async def get_uid_and_ch(
self,
user_id: int,
args: List[str],
reply: Optional["Message"],
player_id: int,
offset: int,
) -> Tuple[Optional[int], Optional[str]]:
"""通过消息获取 uid优先级args > reply > self"""
uid, ch_name, user_id_ = player_id, None, user_id
if args:
for i in args:
if i is not None and not i.startswith("@"):
ch_name = roleToName(i)
if reply:
try:
user_id_ = reply.from_user.id
except AttributeError:
pass
if not uid:
player_info = await self.player_service.get_player(user_id_, offset=offset)
if player_info is not None:
uid = player_info.player_id
if (not uid) and (user_id_ != user_id):
player_info = await self.player_service.get_player(user_id, offset=offset)
if player_info is not None:
uid = player_info.player_id
return uid, ch_name
@staticmethod
def get_caption(character: "CharacterInfo") -> str:
tags = [character.name, f"等级{character.level}", f"命座{character.constellations_unlocked}"]
if character.equipments:
for item in character.equipments:
if item.type == EquipmentsType.WEAPON and item.detail:
tags.append(item.detail.name)
tags.append(f"武器等级{item.level}")
tags.append(f"{item.refinement}")
return "#" + " #".join(tags)
@handler.command(command="player_card", player=True, block=False)
@handler.command(command="player_cards", player=True, block=False)
@handler.message(filters=filters.Regex("^角色卡片查询(.*)"), player=True, block=False)
async def player_cards(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
message = update.effective_message
args = self.get_args(context)
await message.reply_chat_action(ChatAction.TYPING)
uid, offset = self.get_real_uid_or_offset(update)
uid, character_name = await self.get_uid_and_ch(user_id, args, message.reply_to_message, uid, offset)
if uid is None:
raise PlayerNotFoundError(user_id)
original_data = await self._load_history(uid)
if original_data is None or len(original_data["avatarInfoList"]) == 0:
if isinstance(self.kitsune, str):
photo = self.kitsune
else:
photo = open("resources/img/kitsune.png", "rb")
buttons = [
[
InlineKeyboardButton(
"更新面板",
callback_data=f"update_player_card|{user_id}|{uid}",
)
]
]
reply_message = await message.reply_photo(
photo=photo,
caption="角色列表未找到,请尝试点击下方按钮从 Enka.Network 更新角色列表",
reply_markup=InlineKeyboardMarkup(buttons),
)
if reply_message.photo:
self.kitsune = reply_message.photo[-1].file_id
return
enka_response = EnkaNetworkResponse.parse_obj(copy.deepcopy(original_data))
if character_name is None:
self.log_user(update, logger.info, "角色卡片查询命令请求")
ttl = await self.cache.ttl(uid)
if enka_response.characters is None or len(enka_response.characters) == 0:
buttons = [
[
InlineKeyboardButton(
"更新面板",
callback_data=f"update_player_card|{user_id}|{uid}",
)
]
]
else:
buttons = self.gen_button(enka_response, user_id, uid, update_button=ttl < 0)
if isinstance(self.kitsune, str):
photo = self.kitsune
else:
photo = open("resources/img/kitsune.png", "rb")
reply_message = await message.reply_photo(
photo=photo,
caption="请选择你要查询的角色",
reply_markup=InlineKeyboardMarkup(buttons),
)
if reply_message.photo:
self.kitsune = reply_message.photo[-1].file_id
return
self.log_user(
update,
logger.info,
"角色卡片查询命令请求 || character_name[%s] uid[%s]",
character_name,
uid,
)
for characters in enka_response.characters:
if characters.name == character_name:
break
else:
await message.reply_text(
f"角色展柜中未找到 {character_name} ,请检查角色是否存在于角色展柜中,或者等待角色数据更新后重试"
)
return
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
original_data: Optional[Dict] = None
if GENSHIN_ARTIFACT_FUNCTION_AVAILABLE:
original_data = await self._load_history(uid)
render_result = await RenderTemplate(
uid,
characters,
self.fight_prop_rule,
self.damage_config,
self.template_service,
original_data,
).render() # pylint: disable=W0631
await render_result.reply_photo(
message,
filename=f"player_card_{uid}_{character_name}.png",
caption=self.get_caption(characters),
)
@handler.callback_query(pattern=r"^update_player_card\|", block=False)
async def update_player_card(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
user = update.effective_user
message = update.effective_message
callback_query = update.callback_query
async def get_player_card_callback(callback_query_data: str) -> Tuple[int, int]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_uid = int(_data[2])
logger.debug("callback_query_data函数返回 user_id[%s] uid[%s]", _user_id, _uid)
return _user_id, _uid
user_id, uid = await get_player_card_callback(callback_query.data)
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
ttl = await self.cache.ttl(uid)
if ttl > 0:
await callback_query.answer(text=f"请等待 {ttl} 秒后再更新", show_alert=True)
return
await message.reply_chat_action(ChatAction.TYPING)
data = await self._update_enka_data(uid)
if isinstance(data, str):
await callback_query.answer(text=data, show_alert=True)
return
if data.characters is None or len(data.characters) == 0:
await callback_query.answer(
"请先将角色加入到角色展柜并允许查看角色详情后再使用此功能,如果已经添加了角色,请等待角色数据更新后重试",
show_alert=True,
)
await message.delete()
return
self.player_gcsim_scripts.remove_fits(uid)
await callback_query.answer(text="正在从 Enka.Network 获取角色列表 请不要重复点击按钮")
buttons = self.gen_button(data, user.id, uid, update_button=False)
render_data = await self.parse_holder_data(data)
holder = await self.template_service.render(
"genshin/player_card/holder.jinja2",
render_data,
viewport={"width": 750, "height": 580},
ttl=60 * 10,
caption="更新角色列表成功,请选择你要查询的角色",
)
await holder.edit_media(message, reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^get_player_card\|", block=False)
async def get_player_cards(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
async def get_player_card_callback(
callback_query_data: str,
) -> Tuple[str, int, int]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_uid = int(_data[2])
_result = _data[3]
logger.debug(
"callback_query_data函数返回 result[%s] user_id[%s] uid[%s]",
_result,
_user_id,
_uid,
)
return _result, _user_id, _uid
result, user_id, uid = await get_player_card_callback(callback_query.data)
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
if result == "empty_data":
await callback_query.answer(text="此按钮不可用", show_alert=True)
return
page = 0
if result.isdigit():
page = int(result)
logger.info(
"用户 %s[%s] 角色卡片查询命令请求 || page[%s] uid[%s]",
user.full_name,
user.id,
page,
uid,
)
else:
logger.info(
"用户 %s[%s] 角色卡片查询命令请求 || character_name[%s] uid[%s]",
user.full_name,
user.id,
result,
uid,
)
original_data = await self._load_history(uid)
enka_response = EnkaNetworkResponse.parse_obj(copy.deepcopy(original_data))
if enka_response.characters is None or len(enka_response.characters) == 0:
await callback_query.answer(
"请先将角色加入到角色展柜并允许查看角色详情后再使用此功能,如果已经添加了角色,请等待角色数据更新后重试",
show_alert=True,
)
await message.delete()
return
if page:
buttons = self.gen_button(enka_response, user.id, uid, page, await self.cache.ttl(uid) <= 0)
await message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
await callback_query.answer(f"已切换到第 {page}", show_alert=False)
return
for characters in enka_response.characters:
if characters.name == result:
break
else:
await message.delete()
await callback_query.answer(
f"角色展柜中未找到 {result} ,请检查角色是否存在于角色展柜中,或者等待角色数据更新后重试",
show_alert=True,
)
return
await callback_query.answer(text="正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
render_result = await RenderTemplate(
uid, characters, self.fight_prop_rule, self.damage_config, self.template_service, original_data
).render() # pylint: disable=W0631
render_result.filename = f"player_card_{uid}_{result}.png"
render_result.caption = self.get_caption(characters)
await render_result.edit_media(message)
@staticmethod
def gen_button(
data: EnkaNetworkResponse,
user_id: Union[str, int],
uid: int,
page: int = 1,
update_button: bool = True,
) -> List[List[InlineKeyboardButton]]:
"""生成按钮"""
buttons = []
if data.characters:
buttons = [
InlineKeyboardButton(
value.name,
callback_data=f"get_player_card|{user_id}|{uid}|{value.name}",
)
for value in data.characters
if value.name
]
all_buttons = [buttons[i : i + 4] for i in range(0, len(buttons), 4)]
send_buttons = all_buttons[(page - 1) * 3 : page * 3]
last_page = page - 1 if page > 1 else 0
all_page = math.ceil(len(all_buttons) / 3)
next_page = page + 1 if page < all_page and all_page > 1 else 0
last_button = []
if last_page:
last_button.append(
InlineKeyboardButton(
"<< 上一页",
callback_data=f"get_player_card|{user_id}|{uid}|{last_page}",
)
)
if last_page or next_page:
last_button.append(
InlineKeyboardButton(
f"{page}/{all_page}",
callback_data=f"get_player_card|{user_id}|{uid}|empty_data",
)
)
if update_button:
last_button.append(
InlineKeyboardButton(
"更新面板",
callback_data=f"update_player_card|{user_id}|{uid}",
)
)
if next_page:
last_button.append(
InlineKeyboardButton(
"下一页 >>",
callback_data=f"get_player_card|{user_id}|{uid}|{next_page}",
)
)
if last_button:
send_buttons.append(last_button)
return send_buttons
async def parse_holder_data(self, data: EnkaNetworkResponse) -> dict:
"""
生成渲染所需数据
"""
characters_data = []
for idx, character in enumerate(data.characters):
characters_data.append(
{
"level": character.level,
"element": character.element.name,
"constellation": character.constellations_unlocked,
"rarity": character.rarity,
"icon": (await self.assets_service.avatar(character.id).icon()).as_uri(),
}
)
if idx > 6:
break
return {
"uid": mask_number(data.uid),
"level": data.player.level,
"signature": data.player.signature,
"characters": characters_data,
}
class Artifact(BaseModel):
"""在 enka Equipments model 基础上扩展了圣遗物评分数据"""
equipment: Equipments
# 圣遗物评分
score: float = 0
# 圣遗物评级
score_label: str = "E"
# 圣遗物评级颜色
score_class: str = ""
# 圣遗物单行属性评分
substat_scores: List[float]
def __init__(self, **kwargs):
super().__init__(**kwargs)
for substat_scores in self.substat_scores:
self.score += substat_scores
self.score = round(self.score, 1)
for r in (
("D", 10),
("C", 16.5),
("B", 23.1),
("A", 29.7),
("S", 36.3),
("SS", 42.9),
("SSS", 49.5),
("ACE", 56.1),
("ACE²", 66),
):
if self.score >= r[1]:
self.score_label = r[0]
self.score_class = self.get_score_class(r[0])
@staticmethod
def get_score_class(label: str) -> str:
mapping = {
"D": "text-neutral-400",
"C": "text-neutral-200",
"B": "text-violet-400",
"A": "text-violet-400",
"S": "text-yellow-400",
"SS": "text-yellow-400",
"SSS": "text-yellow-400",
"ACE": "text-red-500",
"ACE²": "text-red-500",
}
return mapping.get(label, "text-neutral-400")
class RenderTemplate:
def __init__(
self,
uid: Union[int, str],
character: "CharacterInfo",
fight_prop_rule: Dict[str, Dict[str, float]],
damage_config: Dict,
template_service: TemplateService,
original_data: Optional[Dict] = None,
):
self.uid = uid
self.template_service = template_service
# 因为需要替换线上 enka 图片地址为本地地址,先克隆数据,避免修改原数据
self.character = character.copy(deep=True)
self.fight_prop_rule = fight_prop_rule
self.original_data = original_data
self.damage_config = damage_config
async def render(self):
# 缓存所有图片到本地
await self.cache_images()
artifacts = self.find_artifacts()
artifact_total_score: float = sum(artifact.score for artifact in artifacts)
artifact_total_score = round(artifact_total_score, 1)
artifact_total_score_label: str = "E"
for r in (
("D", 10),
("C", 16.5),
("B", 23.1),
("A", 29.7),
("S", 36.3),
("SS", 42.9),
("SSS", 49.5),
("ACE", 56.1),
("ACE²", 66),
):
if artifact_total_score / 5 >= r[1]:
artifact_total_score_label = r[0]
data = {
"uid": mask_number(self.uid),
"character": self.character,
"stats": await self.de_stats(),
"weapon": self.find_weapon(),
# 圣遗物评分
"artifact_total_score": artifact_total_score,
# 圣遗物评级
"artifact_total_score_label": artifact_total_score_label,
# 圣遗物评级颜色
"artifact_total_score_class": Artifact.get_score_class(artifact_total_score_label),
"artifacts": artifacts,
# 需要在模板中使用的 enum 类型
"DigitType": DigitType,
"damage_function_available": False,
"damage_info": [],
}
if GENSHIN_ARTIFACT_FUNCTION_AVAILABLE:
character_cn_name = idToName(self.character.id)
damage_config = self.damage_config.get(character_cn_name)
if damage_config is not None:
try:
data["damage_info"] = self.render_damage(damage_config)
except JsonParseException as _exc:
logger.error(str(_exc))
except EnkaParseException as _exc:
logger.error(str(_exc))
else:
data["damage_function_available"] = True
return await self.template_service.render(
"genshin/player_card/player_card.jinja2",
data,
full_page=True,
query_selector=".text-neutral-200",
ttl=7 * 24 * 60 * 60,
)
def render_damage(self, damage_config: Optional[Dict]) -> List:
character, weapon, artifacts = enka_parser(self.original_data, self.character.id)
character_name = character.name
character_cn_name = idToName(self.character.id)
if damage_config is None:
damage_config = self.damage_config.get(character_cn_name)
skills = damage_config.get("skills")
config_skill = damage_config.get("config_skill")
if config_skill is not None:
config_skill = {character_name: config_skill}
character_config = damage_config.get("config")
artifact_config = damage_config.get("artifact_config")
if character_config is not None:
character.params = {character_name: character_config}
config_weapon = damage_config.get("config_weapon")
if config_weapon is not None:
_weapon_config = config_weapon.get(weapon.name)
if _weapon_config is not None:
weapon.params = {weapon.name: _weapon_config}
damage = []
for skill in skills:
index = skill.get("index")
skill_info = SkillInterface(index=index, config=config_skill)
calculator_config = CalculatorConfig(
character=character,
weapon=weapon,
artifacts=artifacts,
skill=skill_info,
artifact_config=artifact_config,
)
damage_key = skill.get("damage_key")
transformative_damage_key = skill.get("transformative_damage_key")
damage_info = {"skill_info": skill, "damage": None, "transformative_damage": None}
if damage_key is not None:
damage_analysis = get_damage_analysis(calculator_config)
damage_value = getattr(damage_analysis, damage_key)
damage_info["damage"] = damage_value
if transformative_damage_key is not None:
transformative_damage = get_transformative_damage(calculator_config)
transformative_damage_value = getattr(transformative_damage, transformative_damage_key)
damage_info["transformative_damage"] = transformative_damage_value
damage.append(damage_info)
return damage
async def de_stats(self) -> List[Tuple[str, Any]]:
stats = self.character.stats
items: List[Tuple[str, Any]] = []
logger.debug(self.character.stats)
# items.append(("基础生命值", stats.BASE_HP.to_rounded()))
items.append(("生命值", stats.FIGHT_PROP_MAX_HP.to_rounded()))
# items.append(("基础攻击力", stats.FIGHT_PROP_BASE_ATTACK.to_rounded()))
items.append(("攻击力", stats.FIGHT_PROP_CUR_ATTACK.to_rounded()))
# items.append(("基础防御力", stats.FIGHT_PROP_BASE_DEFENSE.to_rounded()))
items.append(("防御力", stats.FIGHT_PROP_CUR_DEFENSE.to_rounded()))
items.append(("暴击率", stats.FIGHT_PROP_CRITICAL.to_percentage_symbol()))
items.append(
(
"暴击伤害",
stats.FIGHT_PROP_CRITICAL_HURT.to_percentage_symbol(),
)
)
items.append(
(
"元素充能效率",
stats.FIGHT_PROP_CHARGE_EFFICIENCY.to_percentage_symbol(),
)
)
items.append(("元素精通", stats.FIGHT_PROP_ELEMENT_MASTERY.to_rounded()))
# 查找元素伤害加成和治疗加成
max_stat = StatsPercentage() # 用于记录最高元素伤害加成 避免武器特效影响
for stat in stats:
if 40 <= stat[1].id <= 46: # 元素伤害加成
if max_stat.value <= stat[1].value:
max_stat = stat[1]
elif stat[1].id == 29: # 物理伤害加成
pass
elif stat[1].id != 26: # 治疗加成
continue
value = stat[1].to_rounded() if isinstance(stat[1], Stats) else stat[1].to_percentage_symbol()
if value in ("0%", 0):
continue
name = DEFAULT_EnkaAssets.get_hash_map(stat[0])
if name is None:
continue
items.append((name, value))
if max_stat.id != 0:
for item in items:
if "元素伤害加成" in item[0] and max_stat.to_percentage_symbol() != item[1]:
items.remove(item)
return items
async def cache_images(self) -> None:
"""缓存所有图片到本地"""
# TODO: 并发下载所有资源
c = self.character
# 角色
c.image.banner.url = await download_resource(c.image.banner.url)
# 技能
for item in c.skills:
item.icon.url = await download_resource(item.icon.url)
# 命座
for item in c.constellations:
item.icon.url = await download_resource(item.icon.url)
# 装备,包括圣遗物和武器
for item in c.equipments:
item.detail.icon.url = await download_resource(item.detail.icon.url)
def find_weapon(self) -> Optional[Equipments]:
"""在 equipments 数组中找到武器equipments 数组包含圣遗物和武器"""
for item in self.character.equipments:
if item.type == EquipmentsType.WEAPON:
return item
return None
def find_artifacts(self) -> List[Artifact]:
"""在 equipments 数组中找到圣遗物,并转换成带有分数的 model。equipments 数组包含圣遗物和武器"""
stats = ArtifactStatsTheory(self.character.name, self.fight_prop_rule)
def substat_score(s: "EquipmentsStats") -> float:
return stats.theory(s)
return [
Artifact(
equipment=e,
# 圣遗物单行属性评分
substat_scores=[substat_score(s) for s in e.detail.substats],
)
for e in self.character.equipments
if e.type == EquipmentsType.ARTIFACT
]

View File

@ -1,67 +0,0 @@
import random
from telegram import Poll, Update
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import filters, CallbackContext
from core.plugin import Plugin, handler
from core.services.quiz.services import QuizService
from core.services.users.services import UserService
from utils.log import logger
__all__ = ("QuizPlugin",)
class QuizPlugin(Plugin):
"""派蒙的十万个为什么"""
def __init__(self, quiz_service: QuizService = None, user_service: UserService = None):
self.user_service = user_service
self.quiz_service = quiz_service
self.time_out = 120
@handler.message(filters=filters.Regex("来一道题"))
@handler.command(command="quiz", block=False)
async def command_start(self, update: Update, _: CallbackContext) -> None:
message = update.effective_message
chat = update.effective_chat
await message.reply_chat_action(ChatAction.TYPING)
question_id_list = await self.quiz_service.get_question_id_list()
if filters.ChatType.GROUPS.filter(message):
self.log_user(update, logger.info, "在群 %s[%s] 发送挑战问题命令请求", chat.title, chat.id)
if len(question_id_list) == 0:
return None
if len(question_id_list) == 0:
return None
question_id = random.choice(question_id_list) # nosec
question = await self.quiz_service.get_question(question_id)
_options = []
correct_option = None
for answer in question.answers:
_options.append(answer.text)
if answer.is_correct:
correct_option = answer.text
if correct_option is None:
question_id = question["question_id"]
logger.warning("Quiz模块 correct_option 异常 question_id[%s]", question_id)
return None
random.shuffle(_options)
index = _options.index(correct_option)
try:
poll_message = await message.reply_poll(
question.text,
_options,
correct_option_id=index,
is_anonymous=False,
open_period=self.time_out,
type=Poll.QUIZ,
)
except BadRequest as exc:
if "Not enough rights" in exc.message:
poll_message = await message.reply_text("出错了呜呜呜 ~ 权限不足,请请检查投票权限是否开启")
else:
raise exc
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(message, delay=300)
self.add_delete_message_job(poll_message, delay=300)

View File

@ -1,96 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
from simnet.client.routes import InternationalRoute
from simnet.errors import BadRequest as SIMNetBadRequest
from simnet.utils.player import recognize_genshin_server, recognize_genshin_game_biz
from telegram.ext import filters
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.cookies import CookiesService
from core.services.users.services import UserService
from plugins.tools.genshin import GenshinHelper
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
from simnet import GenshinClient
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
REG_TIME_URL = InternationalRoute(
overseas="https://sg-hk4e-api.hoyoverse.com/event/e20220928anniversary/game_data",
chinese="https://hk4e-api.mihoyo.com/event/e20220928anniversary/game_data",
)
class NotFoundRegTimeError(Exception):
"""未找到注册时间"""
class RegTimePlugin(Plugin):
"""查询原神注册时间"""
def __init__(
self,
user_service: UserService = None,
cookie_service: CookiesService = None,
helper: GenshinHelper = None,
redis: RedisDB = None,
):
self.cache = redis.client
self.cache_key = "plugin:reg_time:"
self.user_service = user_service
self.cookie_service = cookie_service
self.helper = helper
@staticmethod
async def get_reg_time(client: "GenshinClient") -> str:
"""获取原神注册时间"""
game_biz = recognize_genshin_game_biz(client.player_id)
region = recognize_genshin_server(client.player_id)
await client.get_hk4e_token_by_cookie_token(game_biz, region)
url = REG_TIME_URL.get_url(client.region)
params = {"game_biz": game_biz, "lang": "zh-cn", "badge_uid": client.player_id, "badge_region": region}
data = await client.request_lab(url, method="GET", params=params)
if time := jsonlib.loads(data.get("data", "{}")).get("1", 0):
return datetime.fromtimestamp(time).strftime("%Y-%m-%d %H:%M:%S")
raise NotFoundRegTimeError
async def get_reg_time_from_cache(self, client: "GenshinClient") -> str:
"""从缓存中获取原神注册时间"""
if reg_time := await self.cache.get(f"{self.cache_key}{client.player_id}"):
return reg_time.decode("utf-8")
reg_time = await self.get_reg_time(client)
await self.cache.set(f"{self.cache_key}{client.player_id}", reg_time)
return reg_time
@handler.command("reg_time", block=False)
@handler.message(filters.Regex(r"^原神账号注册时间$"), block=False)
async def reg_time(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
self.log_user(update, logger.info, "原神注册时间命令请求")
try:
async with self.helper.genshin(user_id, player_id=uid, offset=offset) as client:
reg_time = await self.get_reg_time_from_cache(client)
await message.reply_text(f"你的原神账号注册时间为:{reg_time}")
except SIMNetBadRequest as exc:
if exc.ret_code == -501101:
await message.reply_text("当前角色冒险等阶未达到10级暂时无法获取信息")
else:
raise exc
except ValueError as exc:
if "cookie_token" in str(exc):
await message.reply_text("呜呜呜出错了请重新绑定账号")
else:
raise exc
except NotFoundRegTimeError:
await message.reply_text("未找到你的原神账号注册时间,仅限 2022 年 10 月 之前注册的账号")

View File

@ -1,75 +0,0 @@
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatAction, ParseMode
from telegram.ext import CallbackContext, filters
from core.plugin import Plugin, handler
from core.services.game.services import GameStrategyService
from core.services.search.models import StrategyEntry
from core.services.search.services import SearchServices
from metadata.shortname import roleToName, roleToTag
from utils.log import logger
class StrategyPlugin(Plugin):
"""角色攻略查询"""
KEYBOARD = [
[InlineKeyboardButton(text="查看角色攻略列表并查询", switch_inline_query_current_chat="查看角色攻略列表并查询")]
]
def __init__(
self,
game_strategy_service: GameStrategyService = None,
search_service: SearchServices = None,
):
self.game_strategy_service = game_strategy_service
self.search_service = search_service
@handler.command(command="strategy", block=False)
@handler.message(filters=filters.Regex("^角色攻略查询(.*)"), block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
args = self.get_args(context)
if len(args) >= 1:
character_name = args[0]
else:
reply_message = await message.reply_text(
"请回复你要查询的攻略的角色名", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
character_name = roleToName(character_name)
url = await self.game_strategy_service.get_strategy(character_name)
if url == "":
reply_message = await message.reply_text(
f"没有找到 {character_name} 的攻略", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
self.log_user(update, logger.info, "查询角色攻略命令请求 || 参数 %s", character_name)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
file_path = await self.download_resource(url, return_path=True)
caption = f"From 米游社 西风驿站 查看<a href='{url}'>原图</a>"
reply_photo = await message.reply_photo(
photo=open(file_path, "rb"),
caption=caption,
filename=f"{character_name}.png",
parse_mode=ParseMode.HTML,
)
if reply_photo.photo:
tags = roleToTag(character_name)
photo_file_id = reply_photo.photo[0].file_id
entry = StrategyEntry(
key=f"plugin:strategy:{character_name}",
title=character_name,
description=f"{character_name} 角色攻略",
tags=tags,
caption=caption,
parse_mode="HTML",
photo_file_id=photo_file_id,
)
await self.search_service.add_entry(entry)

View File

@ -1,145 +0,0 @@
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatAction
from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters
from core.dependence.assets import AssetsCouldNotFound, AssetsService
from core.plugin import Plugin, handler
from core.services.search.models import WeaponEntry
from core.services.search.services import SearchServices
from core.services.template.services import TemplateService
from core.services.wiki.services import WikiService
from metadata.genshin import honey_id_to_game_id
from metadata.shortname import weaponToName, weapons as _weapons_data
from modules.wiki.weapon import Weapon
from utils.log import logger
class WeaponPlugin(Plugin):
"""武器查询"""
KEYBOARD = [
[InlineKeyboardButton(text="查看武器列表并查询", switch_inline_query_current_chat="查看武器列表并查询")]
]
def __init__(
self,
template_service: TemplateService = None,
wiki_service: WikiService = None,
assets_service: AssetsService = None,
search_service: SearchServices = None,
):
self.wiki_service = wiki_service
self.template_service = template_service
self.assets_service = assets_service
self.search_service = search_service
@handler(CommandHandler, command="weapon", block=False)
@handler(MessageHandler, filters=filters.Regex("^武器查询(.*)"), block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
args = self.get_args(context)
if len(args) >= 1:
weapon_name = args[0]
else:
reply_message = await message.reply_text(
"请回复你要查询的武器", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
weapon_name = weaponToName(weapon_name)
self.log_user(update, logger.info, "查询角色攻略命令请求 weapon_name[%s]", weapon_name)
weapons_list = await self.wiki_service.get_weapons_list()
for weapon in weapons_list:
if weapon.name == weapon_name:
weapon_data = weapon
break
else:
reply_message = await message.reply_text(
f"没有找到 {weapon_name}", reply_markup=InlineKeyboardMarkup(self.KEYBOARD)
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
await message.reply_chat_action(ChatAction.TYPING)
async def input_template_data(_weapon_data: Weapon):
if weapon.rarity > 2:
bonus = _weapon_data.stats[-1].bonus
if "%" in bonus:
bonus = str(round(float(bonus.rstrip("%")))) + "%"
else:
bonus = str(round(float(bonus)))
_template_data = {
"weapon_name": _weapon_data.name,
"weapon_rarity": _weapon_data.rarity,
"weapon_info_type_img": _weapon_data.weapon_type.name,
"progression_secondary_stat_value": bonus,
"progression_secondary_stat_name": _weapon_data.attribute.type.value,
"weapon_info_source_img": (
await self.assets_service.weapon(honey_id_to_game_id(_weapon_data.id, "weapon")).icon()
).as_uri(),
"weapon_info_max_level": _weapon_data.stats[-1].level,
"progression_base_atk": round(_weapon_data.stats[-1].ATK),
"weapon_info_source_list": [
(await self.assets_service.material(honey_id_to_game_id(mid, "material")).icon()).as_uri()
for mid in _weapon_data.ascension[-3:]
],
"special_ability_name": _weapon_data.affix.name,
"special_ability_info": _weapon_data.affix.description[0],
"weapon_description": _weapon_data.description,
}
else:
_template_data = {
"weapon_name": _weapon_data.name,
"weapon_rarity": _weapon_data.rarity,
"weapon_info_type_img": _weapon_data.weapon_type.name,
"progression_secondary_stat_value": " ",
"progression_secondary_stat_name": "无其它属性加成",
"weapon_info_source_img": (
await self.assets_service.weapon(honey_id_to_game_id(_weapon_data.id, "weapon")).icon()
).as_uri(),
"weapon_info_max_level": _weapon_data.stats[-1].level,
"progression_base_atk": round(_weapon_data.stats[-1].ATK),
"weapon_info_source_list": [
(await self.assets_service.material(honey_id_to_game_id(mid, "material")).icon()).as_uri()
for mid in _weapon_data.ascension[-3:]
],
"special_ability_name": "",
"special_ability_info": "",
"weapon_description": _weapon_data.description,
}
return _template_data
try:
template_data = await input_template_data(weapon_data)
except AssetsCouldNotFound as exc:
logger.warning("%s weapon_name[%s]", exc.message, weapon_name)
reply_message = await message.reply_text(f"数据库中没有找到 {weapon_name}")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message)
self.add_delete_message_job(reply_message)
return
png_data = await self.template_service.render(
"genshin/weapon/weapon.jinja2", template_data, {"width": 540, "height": 540}, ttl=31 * 24 * 60 * 60
)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
reply_photo = await png_data.reply_photo(
message,
filename=f"{template_data['weapon_name']}.png",
)
if reply_photo.photo:
description = weapon_data.story
if description:
photo_file_id = reply_photo.photo[0].file_id
tags = _weapons_data.get(weapon_name)
entry = WeaponEntry(
key=f"plugin:weapon:{weapon_name}",
title=weapon_name,
description=description,
tags=tags,
photo_file_id=photo_file_id,
)
await self.search_service.add_entry(entry)

View File

@ -1,327 +0,0 @@
import asyncio
import re
from datetime import datetime
from typing import Any, List, Optional, Tuple, Union
from bs4 import BeautifulSoup
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters
from core.dependence.assets import AssetsService
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.template.services import TemplateService
from metadata.genshin import AVATAR_DATA, WEAPON_DATA, avatar_to_game_id, weapon_to_game_id
from metadata.shortname import weaponToName
from modules.apihelper.client.components.gacha import Gacha as GachaClient
from modules.apihelper.models.genshin.gacha import GachaInfo
from modules.wish.banner import GenshinBannerType, GachaBanner
from modules.wish.player.info import PlayerGachaInfo
from modules.wish.system import BannerSystem
from utils.log import logger
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
class GachaNotFound(Exception):
"""卡池未找到"""
def __init__(self, gacha_name: str):
self.gacha_name = gacha_name
super().__init__(f"{gacha_name} gacha not found")
class GachaDataFound(Exception):
"""卡池数据未找到"""
def __init__(self, item_id: int):
self.item_id = item_id
super().__init__(f"item_id[{item_id}] data not found")
class GachaRedis:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "plugin:wish_simulator:"
self.ex = 60 * 60 * 24
async def get(self, user_id: int) -> PlayerGachaInfo:
data = await self.client.get(f"{self.qname}{user_id}")
if data is None:
return PlayerGachaInfo()
return PlayerGachaInfo(**jsonlib.loads(data))
async def set(self, user_id: int, player_gacha_info: PlayerGachaInfo):
value = player_gacha_info.json()
await self.client.set(f"{self.qname}{user_id}", value, ex=self.ex)
class WishSimulatorHandle:
def __init__(self):
self.hyperion = GachaClient()
async def de_banner(self, gacha_id: str, gacha_type: int) -> Optional[GachaBanner]:
gacha_info = await self.hyperion.get_gacha_info(gacha_id)
banner = GachaBanner()
banner.banner_id = gacha_id
banner.title, banner.html_title = self.de_title(gacha_info["title"])
r5_up_items = gacha_info.get("r5_up_items")
if r5_up_items is not None:
for r5_up_item in r5_up_items:
if r5_up_item["item_type"] == "角色":
banner.rate_up_items5.append(avatar_to_game_id(r5_up_item["item_name"]))
elif r5_up_item["item_type"] == "武器":
banner.rate_up_items5.append(weapon_to_game_id(r5_up_item["item_name"]))
r5_prob_list = gacha_info.get("r5_prob_list")
if r5_prob_list is not None:
for r5_prob in gacha_info.get("r5_prob_list", []):
if r5_prob["item_type"] == "角色":
banner.fallback_items5_pool1.append(avatar_to_game_id(r5_prob["item_name"]))
elif r5_prob["item_type"] == "武器":
banner.fallback_items5_pool1.append(weapon_to_game_id(r5_prob["item_name"]))
r4_up_items = gacha_info.get("r4_up_items")
if r4_up_items is not None:
for r4_up_item in r4_up_items:
if r4_up_item["item_type"] == "角色":
banner.rate_up_items4.append(avatar_to_game_id(r4_up_item["item_name"]))
elif r4_up_item["item_type"] == "武器":
banner.rate_up_items4.append(weapon_to_game_id(r4_up_item["item_name"]))
r4_prob_list = gacha_info.get("r4_prob_list")
if r4_prob_list is not None:
for r4_prob in r4_prob_list:
if r4_prob["item_type"] == "角色":
banner.fallback_items4_pool1.append(avatar_to_game_id(r4_prob["item_name"]))
elif r4_prob["item_type"] == "武器":
banner.fallback_items4_pool1.append(weapon_to_game_id(r4_prob["item_name"]))
if gacha_type in {301, 400}:
banner.wish_max_progress = 1
banner.banner_type = GenshinBannerType.EVENT
banner.weight4 = ((1, 510), (8, 510), (10, 10000))
banner.weight5 = ((1, 60), (73, 60), (90, 10000))
elif gacha_type == 302:
banner.wish_max_progress = 2
banner.banner_type = GenshinBannerType.WEAPON
banner.weight4 = ((1, 600), (7, 600), (10, 10000))
banner.weight5 = ((1, 70), (62, 70), (90, 10000))
else:
banner.banner_type = GenshinBannerType.STANDARD
return banner
async def gacha_base_info(self, gacha_name: str = "角色活动", default: bool = False) -> GachaInfo:
gacha_list_info = await self.hyperion.get_gacha_list_info()
now = datetime.now()
for gacha in gacha_list_info:
if gacha.gacha_name == gacha_name and gacha.begin_time <= now <= gacha.end_time:
return gacha
else: # pylint: disable=W0120
if default and len(gacha_list_info) > 0:
return gacha_list_info[0]
raise GachaNotFound(gacha_name)
@staticmethod
def de_title(title: str) -> Union[Tuple[str, None], Tuple[str, Any]]:
title_html = BeautifulSoup(title, "lxml")
re_color = re.search(r"<color=#(.*?)>", title, flags=0)
if re_color is None:
return title_html.text, None
color = re_color[1]
title_html.color.name = "span"
title_html.span["style"] = f"color:#{color};"
return title_html.text, title_html.p
class WishSimulatorPlugin(Plugin):
"""抽卡模拟器(非首模拟器/减寿模拟器)"""
def __init__(self, assets: AssetsService, template_service: TemplateService, redis: RedisDB):
self.gacha_db = GachaRedis(redis)
self.handle = WishSimulatorHandle()
self.banner_system = BannerSystem()
self.template_service = template_service
self.banner_cache = {}
self._look = asyncio.Lock()
self.assets_service = assets
async def get_banner(self, gacha_base_info: GachaInfo):
async with self._look:
banner = self.banner_cache.get(gacha_base_info.gacha_id)
if banner is None:
banner = await self.handle.de_banner(gacha_base_info.gacha_id, gacha_base_info.gacha_type)
self.banner_cache.setdefault(gacha_base_info.gacha_id, banner)
return banner
async def de_item_list(self, item_list: List[int]) -> List[dict]:
gacha_item: List[dict] = []
for item_id in item_list:
if item_id is None:
continue
if 10000 <= item_id <= 100000:
data = WEAPON_DATA.get(str(item_id))
avatar = self.assets_service.weapon(item_id)
gacha = await avatar.gacha()
if gacha is None:
raise GachaDataFound(item_id)
data.setdefault("url", gacha.as_uri())
gacha_item.append(data)
elif 10000000 <= item_id <= 19999999:
data = AVATAR_DATA.get(str(item_id))
avatar = self.assets_service.avatar(item_id)
gacha = await avatar.gacha_card()
if gacha is None:
raise GachaDataFound(item_id)
data.setdefault("url", gacha.as_uri())
gacha_item.append(data)
return gacha_item
async def shutdown(self) -> None:
pass
# todo 目前清理消息无法执行 因为先停止Job导致无法获取全部信息
# logger.info("正在清理消息")
# job_queue = self.application.telegram.job_queue
# jobs = job_queue.jobs()
# for job in jobs:
# if "wish_simulator" in job.name and not job.removed:
# logger.info("当前Job name %s", job.name)
# try:
# await job.run(job_queue.application)
# except CancelledError:
# continue
# except Exception as exc:
# logger.warning("执行失败 %", str(exc))
# else:
# logger.info("Jobs为空")
# logger.success("清理卡池消息成功")
@handler(CommandHandler, command="wish", block=False)
@handler(MessageHandler, filters=filters.Regex("^抽卡模拟器(.*)"), block=False)
async def command_start(self, update: Update, context: CallbackContext) -> None:
user_id = await self.get_real_user_id(update)
message = update.effective_message
args = self.get_args(context)
gacha_name = "角色活动"
if len(args) >= 1:
gacha_name = args[0]
if gacha_name not in ("角色活动-2", "武器活动", "常驻", "角色活动"):
for key, value in {"2": "角色活动-2", "武器": "武器活动", "普通": "常驻"}.items():
if key == gacha_name:
gacha_name = value
break
try:
gacha_base_info = await self.handle.gacha_base_info(gacha_name)
except GachaNotFound as exc:
await message.reply_text(
f"没有找到名为 {exc.gacha_name} 的卡池,可能是卡池不存在或者卡池已经结束,请检查后重试。如果你想抽取默认卡池,请不要输入参数。"
)
return
else:
try:
gacha_base_info = await self.handle.gacha_base_info(default=True)
except GachaNotFound:
await message.reply_text("当前卡池正在替换中,请稍后重试。")
return
self.log_user(update, logger.info, "抽卡模拟器命令请求 || 参数 %s", gacha_name)
# 用户数据储存和处理
await message.reply_chat_action(ChatAction.TYPING)
banner = await self.get_banner(gacha_base_info)
player_gacha_info = await self.gacha_db.get(user_id)
# 检查 wish_item_id
if (
banner.banner_type == GenshinBannerType.WEAPON
and player_gacha_info.event_weapon_banner.wish_item_id not in banner.rate_up_items5
):
player_gacha_info.event_weapon_banner.wish_item_id = 0
# 执行抽卡
item_list = self.banner_system.do_pulls(player_gacha_info, banner, 10)
try:
data = await self.de_item_list(item_list)
except GachaDataFound as exc:
logger.warning("角色 item_id[%s] 抽卡立绘未找到", exc.item_id)
reply_message = await message.reply_text("出错了呜呜呜 ~ 卡池部分数据未找到!")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_message, name="wish_simulator")
self.add_delete_message_job(message, name="wish_simulator")
return
player_gacha_banner_info = player_gacha_info.get_banner_info(banner)
template_data = {
"name": f"{self.get_real_user_name(update)}",
"info": gacha_name,
"banner_name": banner.html_title if banner.html_title else banner.title,
"banner_type": banner.banner_type.name,
"player_gacha_banner_info": player_gacha_banner_info,
"items": [],
"wish_name": "",
}
if player_gacha_banner_info.wish_item_id != 0:
weapon = WEAPON_DATA.get(str(player_gacha_banner_info.wish_item_id))
if weapon is not None:
template_data["wish_name"] = weapon["name"]
await self.gacha_db.set(user_id, player_gacha_info)
def take_rang(elem: dict):
return elem["rank"]
data.sort(key=take_rang, reverse=True)
template_data["items"] = data
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
png_data = await self.template_service.render(
"genshin/wish/wish.jinja2", template_data, {"width": 1157, "height": 603}, False
)
reply_message = await message.reply_photo(png_data.photo)
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply_message, name="wish_simulator")
self.add_delete_message_job(message, name="wish_simulator")
@handler(CommandHandler, command="set_wish", block=False)
@handler(MessageHandler, filters=filters.Regex("^非首模拟器定轨(.*)"), block=False)
async def set_wish(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
user = update.effective_user
args = self.get_args(context)
try:
gacha_base_info = await self.handle.gacha_base_info("武器活动")
except GachaNotFound:
reply_message = await message.reply_text("当前还没有武器正在 UP可能是卡池不存在或者卡池已经结束。")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message, delay=30)
self.add_delete_message_job(reply_message, delay=30)
return
banner = await self.get_banner(gacha_base_info)
up_weapons = {}
for rate_up_items5 in banner.rate_up_items5:
weapon = WEAPON_DATA.get(str(rate_up_items5))
if weapon is None:
continue
up_weapons[weapon["name"]] = rate_up_items5
up_weapons_text = "当前 UP 武器有:" + "".join(up_weapons.keys())
if len(args) >= 1:
weapon_name = args[0]
else:
reply_message = await message.reply_text(f"输入的参数不正确,请输入需要定轨的武器名称。\n{up_weapons_text}")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message, delay=30)
self.add_delete_message_job(reply_message, delay=30)
return
weapon_name = weaponToName(weapon_name)
player_gacha_info = await self.gacha_db.get(user.id)
if weapon_name in up_weapons:
player_gacha_info.event_weapon_banner.wish_item_id = up_weapons[weapon_name]
player_gacha_info.event_weapon_banner.failed_chosen_item_pulls = 0
else:
reply_message = await message.reply_text(
f"输入的参数不正确,可能是没有名为 {weapon_name} 的武器或该武器不存在当前 UP 卡池中\n{up_weapons_text}"
)
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message, delay=30)
self.add_delete_message_job(reply_message, delay=30)
return
await self.gacha_db.set(user.id, player_gacha_info)
reply_message = await message.reply_text(f"抽卡模拟器定轨 {weapon_name} 武器成功")
if filters.ChatType.GROUPS.filter(reply_message):
self.add_delete_message_job(message, delay=30)
self.add_delete_message_job(reply_message, delay=30)

View File

@ -1,634 +0,0 @@
from functools import partial
from io import BytesIO
from typing import Optional, TYPE_CHECKING, List, Union, Tuple
from urllib.parse import urlencode
from aiofiles import open as async_open
from simnet import GenshinClient, Region
from simnet.models.genshin.wish import BannerType
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import ConversationHandler, filters
from telegram.helpers import create_deep_linked_url
from core.basemodel import RegionEnum
from core.dependence.assets import AssetsService
from core.plugin import Plugin, conversation, handler
from core.services.cookies import CookiesService
from core.services.players import PlayersService
from core.services.template.models import FileType
from core.services.template.services import TemplateService
from gram_core.config import config
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from metadata.scripts.paimon_moe import GACHA_LOG_PAIMON_MOE_PATH, update_paimon_moe_zh
from modules.gacha_log.const import UIGF_VERSION, GACHA_TYPE_LIST_REVERSE
from modules.gacha_log.error import (
GachaLogAccountNotFound,
GachaLogAuthkeyTimeout,
GachaLogFileError,
GachaLogInvalidAuthkey,
GachaLogMixedProvider,
GachaLogNotFound,
PaimonMoeGachaLogFileError,
)
from modules.gacha_log.helpers import from_url_get_authkey
from modules.gacha_log.log import GachaLog
from modules.gacha_log.migrate import GachaLogMigrate
from modules.gacha_log.models import GachaLogInfo
from plugins.tools.genshin import PlayerNotFoundError
from plugins.tools.player_info import PlayerInfoSystem
from utils.log import logger
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
if TYPE_CHECKING:
from telegram import Update, Message, User, Document
from telegram.ext import ContextTypes
from gram_core.services.players.models import Player
from gram_core.services.template.models import RenderResult
INPUT_URL, INPUT_FILE, CONFIRM_DELETE = range(10100, 10103)
WAITING = f"{config.notice.bot_name}正在从服务器获取数据,请稍后"
WISHLOG_NOT_FOUND = f"{config.notice.bot_name}没有找到你的抽卡记录,快来私聊{config.notice.bot_name}导入吧~"
class WishLogPlugin(Plugin.Conversation):
"""抽卡记录导入/导出/分析"""
IMPORT_HINT = (
"<b>开始导入祈愿历史记录:请通过 https://paimon.moe/wish/import 获取抽卡记录链接后发送给我"
"(非 paimon.moe 导出的文件数据)</b>\n\n"
f"> 你还可以向{config.notice.bot_name}发送从其他工具导出的 UIGF {UIGF_VERSION} 标准的记录文件\n"
"> 或者从 paimon.moe 、非小酋 导出的 xlsx 记录文件\n"
"> 在绑定 Cookie 时添加 stoken 可能有特殊效果哦(仅限国服)\n"
"<b>注意:导入的数据将会与旧数据进行合并。</b>"
)
def __init__(
self,
template_service: TemplateService,
players_service: PlayersService,
assets: AssetsService,
cookie_service: CookiesService,
player_info: PlayerInfoSystem,
):
self.template_service = template_service
self.players_service = players_service
self.assets_service = assets
self.cookie_service = cookie_service
self.zh_dict = None
self.gacha_log = GachaLog()
self.player_info = player_info
self.wish_photo = None
async def initialize(self) -> None:
await update_paimon_moe_zh(False)
async with async_open(GACHA_LOG_PAIMON_MOE_PATH, "r", encoding="utf-8") as load_f:
self.zh_dict = jsonlib.loads(await load_f.read())
async def get_player_id(self, user_id: int, player_id: int, offset: int) -> int:
"""获取绑定的游戏ID"""
logger.debug("尝试获取已绑定的原神账号")
player = await self.players_service.get_player(user_id, player_id=player_id, offset=offset)
if player is None:
raise PlayerNotFoundError(user_id)
return player.player_id
async def _refresh_user_data(
self, user: "User", player_id: int, data: dict = None, authkey: str = None, verify_uid: bool = True
) -> str:
"""刷新用户数据
:param user: 用户
:param data: 数据
:param authkey: 认证密钥
:return: 返回信息
"""
try:
logger.debug("尝试获取已绑定的原神账号")
if authkey:
new_num = await self.gacha_log.get_gacha_log_data(user.id, player_id, authkey)
return "更新完成,本次没有新增数据" if new_num == 0 else f"更新完成,本次共新增{new_num}条抽卡记录"
if data:
new_num = await self.gacha_log.import_gacha_log_data(user.id, player_id, data, verify_uid)
return "更新完成,本次没有新增数据" if new_num == 0 else f"更新完成,本次共新增{new_num}条抽卡记录"
except GachaLogNotFound:
return WAITING
except GachaLogAccountNotFound:
return "导入失败,可能文件包含的祈愿记录所属 uid 与你当前绑定的 uid 不同"
except GachaLogFileError:
return "导入失败,数据格式错误"
except GachaLogInvalidAuthkey:
return "更新数据失败authkey 无效"
except GachaLogAuthkeyTimeout:
return "更新数据失败authkey 已经过期"
except GachaLogMixedProvider:
return "导入失败,你已经通过其他方式导入过抽卡记录了,本次无法导入"
except PlayerNotFoundError:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
return config.notice.user_not_found
async def import_from_file(
self, user: "User", player_id: int, message: "Message", document: "Optional[Document]" = None
) -> None:
if not document:
document = message.document
# TODO: 使用 mimetype 判断文件类型
if document.file_name.endswith(".xlsx"):
file_type = "xlsx"
elif document.file_name.endswith(".json"):
file_type = "json"
else:
await message.reply_text(
"文件格式错误,请发送符合 UIGF 标准的抽卡记录文件或者 paimon.moe、非小酋导出的 xlsx 格式的抽卡记录文件"
)
return
if document.file_size > 5 * 1024 * 1024:
await message.reply_text("文件过大,请发送小于 5 MB 的文件")
return
try:
out = BytesIO()
await (await document.get_file()).download_to_memory(out=out)
if file_type == "json":
# bytesio to json
data = jsonlib.loads(out.getvalue().decode("utf-8"))
elif file_type == "xlsx":
data = self.gacha_log.convert_xlsx_to_uigf(out, self.zh_dict)
else:
await message.reply_text("文件解析失败,请检查文件")
return
except PaimonMoeGachaLogFileError as exc:
await message.reply_text(
f"导入失败PaimonMoe的抽卡记录当前版本不支持\n支持抽卡记录的版本为 {exc.support_version},你的抽卡记录版本为 {exc.file_version}"
)
return
except GachaLogFileError:
await message.reply_text(f"文件解析失败,请检查文件是否符合 UIGF {UIGF_VERSION} 标准")
return
except (KeyError, IndexError, ValueError):
await message.reply_text(f"文件解析失败,请检查文件编码是否正确或符合 UIGF {UIGF_VERSION} 标准")
return
except Exception as exc:
logger.error("文件解析失败 %s", repr(exc))
await message.reply_text(f"文件解析失败,请检查文件是否符合 UIGF {UIGF_VERSION} 标准")
return
await message.reply_chat_action(ChatAction.TYPING)
reply = await message.reply_text("文件解析成功,正在导入数据")
await message.reply_chat_action(ChatAction.TYPING)
try:
text = await self._refresh_user_data(user, player_id, data=data, verify_uid=file_type == "json")
except Exception as exc: # pylint: disable=W0703
logger.error("文件解析失败 %s", repr(exc))
text = f"文件解析失败,请检查文件是否符合 UIGF {UIGF_VERSION} 标准"
await reply.edit_text(text)
async def can_gen_authkey(self, user_id: int, player_id: int) -> bool:
player_info = await self.players_service.get_player(user_id, region=RegionEnum.HYPERION, player_id=player_id)
if player_info is not None:
cookies = await self.cookie_service.get(user_id, account_id=player_info.account_id)
if (
cookies is not None
and cookies.data
and "stoken" in cookies.data
and next((value for key, value in cookies.data.items() if key in ["ltuid", "login_uid"]), None)
):
return True
return False
async def gen_authkey(self, uid: int) -> Optional[str]:
player_info = await self.players_service.get_player(uid, region=RegionEnum.HYPERION)
if player_info is not None:
cookies = await self.cookie_service.get(uid, account_id=player_info.account_id)
if cookies is not None and cookies.data and "stoken" in cookies.data:
if stuid := next((value for key, value in cookies.data.items() if key in ["ltuid", "login_uid"]), None):
cookies.data["stuid"] = stuid
async with GenshinClient(
cookies=cookies.data, region=Region.CHINESE, lang="zh-cn", player_id=player_info.player_id
) as client:
return await client.get_authkey_by_stoken("webview_gacha")
@conversation.entry_point
@handler.command(command="wish_log_import", filters=filters.ChatType.PRIVATE, block=False)
@handler.command(command="gacha_log_import", filters=filters.ChatType.PRIVATE, block=False)
@handler.message(filters=filters.Regex("^导入抽卡记录(.*)") & filters.ChatType.PRIVATE, block=False)
@handler.command(command="start", filters=filters.Regex("gacha_log_import$"), block=False)
async def command_start(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
user = update.effective_user
player_id = await self.get_player_id(user.id, uid, offset)
context.chat_data["uid"] = player_id
logger.info("用户 %s[%s] 导入抽卡记录命令请求", user.full_name, user.id)
keyboard = None
if await self.can_gen_authkey(user.id, player_id):
keyboard = ReplyKeyboardMarkup([["自动导入"], ["退出"]], one_time_keyboard=True)
await message.reply_text(self.IMPORT_HINT, parse_mode="html", reply_markup=keyboard)
return INPUT_URL
@conversation.state(state=INPUT_URL)
@handler.message(filters=~filters.COMMAND, block=False)
async def import_data_from_message(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
player_id = context.chat_data["uid"]
if message.document:
await self.import_from_file(user, player_id, message)
return ConversationHandler.END
if not message.text:
await message.reply_text("请发送文件或链接")
return INPUT_URL
if message.text == "自动导入":
authkey = await self.gen_authkey(user.id)
if not authkey:
await message.reply_text(
"自动生成 authkey 失败,请尝试通过其他方式导入。", reply_markup=ReplyKeyboardRemove()
)
return ConversationHandler.END
elif message.text == "退出":
await message.reply_text("取消导入抽卡记录", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
else:
authkey = from_url_get_authkey(message.text)
reply = await message.reply_text(WAITING, reply_markup=ReplyKeyboardRemove())
await message.reply_chat_action(ChatAction.TYPING)
text = await self._refresh_user_data(user, player_id, authkey=authkey)
try:
await reply.delete()
except BadRequest:
pass
await message.reply_text(text)
return ConversationHandler.END
@conversation.entry_point
@handler.command(command="wish_log_delete", filters=filters.ChatType.PRIVATE, block=False)
@handler.command(command="gacha_log_delete", filters=filters.ChatType.PRIVATE, block=False)
@handler.message(filters=filters.Regex("^删除抽卡记录(.*)") & filters.ChatType.PRIVATE, block=False)
async def command_start_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 删除抽卡记录命令请求", user.full_name, user.id)
try:
player_id = await self.get_player_id(user.id, uid, offset)
context.chat_data["uid"] = player_id
except PlayerNotFoundError:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
await message.reply_text(config.notice.user_not_found)
return ConversationHandler.END
_, status = await self.gacha_log.load_history_info(str(user.id), str(player_id), only_status=True)
if not status:
await message.reply_text("你还没有导入抽卡记录哦~")
return ConversationHandler.END
await message.reply_text(
"你确定要删除抽卡记录吗?(此项操作无法恢复),如果确定请发送 ”确定“,发送其他内容取消"
)
return CONFIRM_DELETE
@conversation.state(state=CONFIRM_DELETE)
@handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False)
async def command_confirm_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int:
message = update.effective_message
user = update.effective_user
if message.text == "确定":
status = await self.gacha_log.remove_history_info(str(user.id), str(context.chat_data["uid"]))
await message.reply_text("抽卡记录已删除" if status else "抽卡记录删除失败")
return ConversationHandler.END
await message.reply_text("已取消")
return ConversationHandler.END
@handler.command(command="wish_log_force_delete", block=False, admin=True)
@handler.command(command="gacha_log_force_delete", block=False, admin=True)
async def command_gacha_log_force_delete(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 强制删除抽卡记录命令请求", user.full_name, user.id)
args = self.get_args(context)
if not args:
await message.reply_text("请指定用户ID")
return
try:
cid = int(args[0])
if cid < 0:
raise ValueError("Invalid cid")
player_id = await self.get_player_id(cid, uid, offset)
_, status = await self.gacha_log.load_history_info(str(cid), str(player_id), only_status=True)
if not status:
await message.reply_text("该用户还没有导入抽卡记录")
return
status = await self.gacha_log.remove_history_info(str(cid), str(player_id))
await message.reply_text("抽卡记录已强制删除" if status else "抽卡记录删除失败")
except GachaLogNotFound:
await message.reply_text("该用户还没有导入抽卡记录")
except PlayerNotFoundError:
await message.reply_text("该用户暂未绑定账号")
except (ValueError, IndexError):
await message.reply_text("用户ID 不合法")
@handler.command(command="wish_log_export", filters=filters.ChatType.PRIVATE, block=False)
@handler.command(command="gacha_log_export", filters=filters.ChatType.PRIVATE, block=False)
@handler.message(filters=filters.Regex("^导出抽卡记录(.*)") & filters.ChatType.PRIVATE, block=False)
async def command_start_export(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 导出抽卡记录命令请求", user.full_name, user.id)
try:
player_id = await self.get_player_id(user.id, uid, offset)
await message.reply_chat_action(ChatAction.TYPING)
path = await self.gacha_log.gacha_log_to_uigf(str(user.id), str(player_id))
await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT)
await message.reply_document(document=open(path, "rb+"), caption=f"抽卡记录导出文件 - UIGF {UIGF_VERSION}")
except GachaLogNotFound:
logger.info("未找到用户 %s[%s] 的抽卡记录", user.full_name, user.id)
buttons = [
[InlineKeyboardButton("点我导入", url=create_deep_linked_url(context.bot.username, "gacha_log_import"))]
]
await message.reply_text(WISHLOG_NOT_FOUND, reply_markup=InlineKeyboardMarkup(buttons))
except GachaLogAccountNotFound:
await message.reply_text("导入失败,可能文件包含的祈愿记录所属 uid 与你当前绑定的 uid 不同")
except GachaLogFileError:
await message.reply_text("导入失败,数据格式错误")
except PlayerNotFoundError:
logger.info("未查询到用户 %s[%s] 所绑定的账号信息", user.full_name, user.id)
await message.reply_text(config.notice.user_not_found)
@handler.command(command="wish_log_url", filters=filters.ChatType.PRIVATE, block=False)
@handler.command(command="gacha_log_url", filters=filters.ChatType.PRIVATE, block=False)
@handler.message(filters=filters.Regex("^抽卡记录链接(.*)") & filters.ChatType.PRIVATE, block=False)
async def command_start_url(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
message = update.effective_message
user = update.effective_user
logger.info("用户 %s[%s] 生成抽卡记录链接命令请求", user.full_name, user.id)
authkey = await self.gen_authkey(user.id)
if not authkey:
await message.reply_text("生成失败,仅国服且绑定 stoken 的用户才能生成抽卡记录链接")
else:
url = "https://hk4e-api.mihoyo.com/event/gacha_info/api/getGachaLog"
params = {
"authkey_ver": 1,
"lang": "zh-cn",
"gacha_type": 301,
"authkey": authkey,
}
await message.reply_text(f"{url}?{urlencode(params)}", disable_web_page_preview=True)
async def rander_wish_log_analysis(
self, user_id: int, player_id: int, pool_type: BannerType
) -> Union[str, "RenderResult"]:
data = await self.gacha_log.get_analysis(user_id, player_id, pool_type, self.assets_service)
if isinstance(data, str):
return data
name_card = await self.player_info.get_name_card(player_id, user_id)
data["name_card"] = name_card
png_data = await self.template_service.render(
"genshin/wish_log/wish_log.jinja2",
data,
full_page=True,
file_type=FileType.DOCUMENT if len(data.get("fiveLog")) > 300 else FileType.PHOTO,
query_selector=".body_box",
)
return png_data
@staticmethod
def gen_button(user_id: int, uid: int, info: "GachaLogInfo") -> List[List[InlineKeyboardButton]]:
buttons = []
pools = []
skip_pools = []
for k, v in info.item_list.items():
if k in skip_pools:
continue
if not v:
continue
pools.append(k)
# 2 个一组
for i in range(0, len(pools), 2):
row = []
for pool in pools[i : i + 2]:
for k, v in {"log": "", "count": "(按卡池)"}.items():
row.append(
InlineKeyboardButton(
f"{pool.replace('祈愿', '')}{v}",
callback_data=f"get_wish_log|{user_id}|{uid}|{k}|{pool}",
)
)
buttons.append(row)
buttons.append([InlineKeyboardButton("五星抽卡统计", callback_data=f"get_wish_log|{user_id}|{uid}|count|five")])
return buttons
async def wish_log_pool_choose(self, user_id: int, player_id: int, message: "Message"):
await message.reply_chat_action(ChatAction.TYPING)
gacha_log, status = await self.gacha_log.load_history_info(str(user_id), str(player_id))
if not status:
raise GachaLogNotFound
buttons = self.gen_button(user_id, player_id, gacha_log)
if isinstance(self.wish_photo, str):
photo = self.wish_photo
else:
photo = open("resources/img/wish.jpg", "rb")
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
reply_message = await message.reply_photo(
photo=photo,
caption="请选择你要查询的卡池",
reply_markup=InlineKeyboardMarkup(buttons),
)
if reply_message.photo:
self.wish_photo = reply_message.photo[-1].file_id
async def wish_log_pool_send(self, user_id: int, uid: int, pool_type: "BannerType", message: "Message"):
await message.reply_chat_action(ChatAction.TYPING)
png_data = await self.rander_wish_log_analysis(user_id, uid, pool_type)
if isinstance(png_data, str):
reply = await message.reply_text(png_data)
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply)
self.add_delete_message_job(message)
else:
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
if png_data.file_type == FileType.DOCUMENT:
await png_data.reply_document(message, filename="抽卡统计.png")
else:
await png_data.reply_photo(message)
@handler.command(command="wish_log", block=False)
@handler.command(command="gacha_log", block=False)
@handler.message(filters=filters.Regex("^抽卡记录?(武器|角色|常驻|)$"), block=False)
async def command_start_analysis(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
user_id = await self.get_real_user_id(update)
uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message
pool_type = None
if args := self.get_args(context):
if "角色" in args:
pool_type = BannerType.CHARACTER1
elif "武器" in args:
pool_type = BannerType.WEAPON
elif "常驻" in args:
pool_type = BannerType.STANDARD
elif "集录" in args:
pool_type = BannerType.CHRONICLED
self.log_user(update, logger.info, "抽卡记录命令请求 || 参数 %s", pool_type.name if pool_type else None)
try:
player_id = await self.get_player_id(user_id, uid, offset)
if pool_type is None:
await self.wish_log_pool_choose(user_id, player_id, message)
else:
await self.wish_log_pool_send(user_id, player_id, pool_type, message)
except GachaLogNotFound:
self.log_user(update, logger.info, "未找到抽卡记录")
buttons = [
[InlineKeyboardButton("点我导入", url=create_deep_linked_url(context.bot.username, "gacha_log_import"))]
]
await message.reply_text(WISHLOG_NOT_FOUND, reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^get_wish_log\|", block=False)
async def get_wish_log(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
async def get_wish_log_callback(
callback_query_data: str,
) -> Tuple[str, str, int, int]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_uid = int(_data[2])
_t = _data[3]
_result = _data[4]
logger.debug(
"callback_query_data函数返回 result[%s] user_id[%s] uid[%s] show_type[%s]",
_result,
_user_id,
_uid,
_t,
)
return _result, _t, _user_id, _uid
try:
pool, show_type, user_id, uid = await get_wish_log_callback(callback_query.data)
except IndexError:
await callback_query.answer("按钮数据已过期,请重新获取。", show_alert=True)
self.add_delete_message_job(message, delay=1)
return
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
if show_type == "count":
await self.get_wish_log_count(update, user_id, uid, pool)
else:
await self.get_wish_log_log(update, user_id, uid, pool)
async def get_wish_log_log(self, update: "Update", user_id: int, uid: int, pool: str):
callback_query = update.callback_query
message = callback_query.message
pool_type = GACHA_TYPE_LIST_REVERSE.get(pool)
await message.reply_chat_action(ChatAction.TYPING)
try:
png_data = await self.rander_wish_log_analysis(user_id, uid, pool_type)
except GachaLogNotFound:
png_data = "未找到抽卡记录"
if isinstance(png_data, str):
await callback_query.answer(png_data, show_alert=True)
self.add_delete_message_job(message, delay=1)
else:
await callback_query.answer(text="正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
if png_data.file_type == FileType.DOCUMENT:
await png_data.reply_document(message, filename="抽卡统计.png")
self.add_delete_message_job(message, delay=1)
else:
await png_data.edit_media(message)
async def get_wish_log_count(self, update: "Update", user_id: int, uid: int, pool: str):
callback_query = update.callback_query
message = callback_query.message
all_five = pool == "five"
group = filters.ChatType.GROUPS.filter(message)
pool_type = GACHA_TYPE_LIST_REVERSE.get(pool)
await message.reply_chat_action(ChatAction.TYPING)
try:
if all_five:
png_data = await self.gacha_log.get_all_five_analysis(user_id, uid, self.assets_service)
else:
png_data = await self.gacha_log.get_pool_analysis(user_id, uid, pool_type, self.assets_service, group)
except GachaLogNotFound:
png_data = "未找到抽卡记录"
if isinstance(png_data, str):
await callback_query.answer(png_data, show_alert=True)
self.add_delete_message_job(message, delay=1)
else:
await callback_query.answer(text="正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False)
name_card = await self.player_info.get_name_card(uid, user_id)
document = False
if png_data["hasMore"] and not group:
document = True
png_data["hasMore"] = False
png_data["name_card"] = name_card
await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT if document else ChatAction.UPLOAD_PHOTO)
png = await self.template_service.render(
"genshin/wish_count/wish_count.jinja2",
png_data,
full_page=True,
query_selector=".body_box",
file_type=FileType.DOCUMENT if document else FileType.PHOTO,
)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
if document:
await png.reply_document(message, filename="抽卡统计.png")
self.add_delete_message_job(message, delay=1)
else:
await png.edit_media(message)
@staticmethod
async def get_migrate_data(
old_user_id: int, new_user_id: int, old_players: List["Player"]
) -> Optional[GachaLogMigrate]:
return await GachaLogMigrate.create(old_user_id, new_user_id, old_players)
async def wish_log_use_by_inline(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE", pool_type: "BannerType"
):
callback_query = update.callback_query
user = update.effective_user
user_id = user.id
uid = IInlineUseData.get_uid_from_context(context)
self.log_user(update, logger.info, "抽卡记录命令请求 || 参数 %s", pool_type.name if pool_type else None)
notice = None
try:
render_result = await self.rander_wish_log_analysis(user_id, uid, pool_type)
if isinstance(render_result, str):
notice = render_result
else:
await render_result.edit_inline_media(callback_query, filename="抽卡统计.png")
except GachaLogNotFound:
self.log_user(update, logger.info, "未找到抽卡记录")
notice = "未找到抽卡记录"
if notice:
await callback_query.answer(notice, show_alert=True)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
types = {
"角色": BannerType.CHARACTER1,
"武器": BannerType.WEAPON,
"常驻": BannerType.STANDARD,
"集录": BannerType.CHRONICLED,
}
data = []
for k, v in types.items():
data.append(
IInlineUseData(
text=f"{k}祈愿",
hash=f"wish_log_{v.value}",
callback=partial(self.wish_log_use_by_inline, pool_type=v),
player=True,
)
)
return data

View File

@ -1,465 +0,0 @@
import asyncio
import random
import time
from typing import Tuple, Union, Optional, TYPE_CHECKING
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ChatPermissions, ChatMember, Message, User
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import ChatMemberHandler, filters
from telegram.helpers import escape_markdown
from core.config import config
from core.dependence.mtproto import MTProto
from core.dependence.redisdb import RedisDB
from core.handler.callbackqueryhandler import CallbackQueryHandler
from core.plugin import Plugin, handler
from core.services.quiz.services import QuizService
from plugins.tools.chat_administrators import ChatAdministrators
from utils.chatmember import extract_status_change
from utils.log import logger
if TYPE_CHECKING:
from telegram.ext import ContextTypes
from telegram import Update
try:
from pyrogram.errors import BadRequest as MTPBadRequest, FloodWait as MTPFloodWait
PYROGRAM_AVAILABLE = True
except ImportError:
MTPBadRequest = ValueError
MTPFloodWait = IndexError
PYROGRAM_AVAILABLE = False
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
class GroupCaptcha(Plugin):
"""群验证模块"""
def __init__(self, quiz_service: QuizService = None, mtp: MTProto = None, redis: RedisDB = None):
self.quiz_service = quiz_service
self.time_out = 120
self.kick_time = 120
self.mtp = mtp.client
self.cache = redis.client
self.ttl = 60 * 60
self.verify_groups = config.verify_groups
self.user_mismatch = config.notice.user_mismatch
async def initialize(self):
logger.info("群验证模块正在刷新问题列表")
await self.quiz_service.refresh_quiz()
logger.success("群验证模块刷新问题列表成功")
@staticmethod
def mention_markdown(user_id: Union[int, str], version: int = 1) -> str:
tg_link = f"tg://user?id={user_id}"
if version == 1:
return f"[{user_id}]({tg_link})"
return f"[{escape_markdown(user_id, version=version)}]({tg_link})"
async def kick_member_job(self, context: "ContextTypes.DEFAULT_TYPE"):
job = context.job
logger.info("踢出用户 user_id[%s] 在 chat_id[%s]", job.user_id, job.chat_id)
try:
await context.bot.ban_chat_member(
chat_id=job.chat_id, user_id=job.user_id, until_date=int(time.time()) + self.kick_time
)
except BadRequest as exc:
logger.error(
"GroupCaptcha插件在 chat_id[%s] user_id[%s] 执行kick失败", job.chat_id, job.user_id, exc_info=exc
)
@staticmethod
async def clean_message_job(context: "ContextTypes.DEFAULT_TYPE"):
job = context.job
logger.debug("删除消息 chat_id[%s] 的 message_id[%s]", job.chat_id, job.data)
try:
await context.bot.delete_message(chat_id=job.chat_id, message_id=job.data)
except BadRequest as exc:
if "not found" in exc.message:
logger.warning(
"GroupCaptcha插件删除消息 chat_id[%s] message_id[%s]失败 消息不存在", job.chat_id, job.data
)
elif "Message can't be deleted" in exc.message:
logger.warning(
"GroupCaptcha插件删除消息 chat_id[%s] message_id[%s]失败 消息无法删除 可能是没有授权",
job.chat_id,
job.data,
)
else:
logger.error(
"GroupCaptcha插件删除消息 chat_id[%s] message_id[%s]失败", job.chat_id, job.data, exc_info=exc
)
@staticmethod
async def restore_member(context: "ContextTypes.DEFAULT_TYPE", chat_id: int, user_id: int):
logger.debug("重置用户权限 user_id[%s] 在 chat_id[%s]", chat_id, user_id)
try:
await context.bot.restrict_chat_member(
chat_id=chat_id, user_id=user_id, permissions=ChatPermissions.all_permissions()
)
except BadRequest as exc:
logger.error("GroupCaptcha插件在 chat_id[%s] user_id[%s] 执行restore失败", chat_id, user_id, exc_info=exc)
async def get_new_chat_members_message(self, user: User, context: "ContextTypes.DEFAULT_TYPE") -> Optional[Message]:
qname = f"plugin:group_captcha:new_chat_members_message:{user.id}"
result = await self.cache.get(qname)
if result:
data = jsonlib.loads(str(result, encoding="utf-8"))
return Message.de_json(data, context.bot)
return None
async def set_new_chat_members_message(self, user: User, message: Message):
qname = f"plugin:group_captcha:new_chat_members_message:{user.id}"
await self.cache.set(qname, message.to_json(), ex=60)
@handler(CallbackQueryHandler, pattern=r"^auth_admin\|", block=False)
async def admin(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
async def admin_callback(callback_query_data: str) -> Tuple[str, int]:
_data = callback_query_data.split("|")
_result = _data[1]
_user_id = int(_data[2])
logger.debug("admin_callback函数返回 result[%s] user_id[%s]", _result, _user_id)
return _result, _user_id
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
chat = message.chat
logger.info("用户 %s[%s] 在群 %s[%s] 点击Auth管理员命令", user.full_name, user.id, chat.title, chat.id)
chat_administrators = await ChatAdministrators.get_chat_administrators(self.cache, context, chat_id=chat.id)
if not ChatAdministrators.is_admin(chat_administrators, user.id):
logger.debug("用户 %s[%s] 在群 %s[%s] 非群管理", user.full_name, user.id, chat.title, chat.id)
await callback_query.answer(text="你不是管理!\n" + self.user_mismatch, show_alert=True)
return
result, user_id = await admin_callback(callback_query.data)
try:
member_info = await context.bot.get_chat_member(chat.id, user_id)
except BadRequest as error:
logger.warning("获取用户 %s 在群 %s[%s] 信息失败 \n %s", user_id, chat.title, chat.id, error.message)
member_info = f"{user_id}"
if result == "pass":
await callback_query.answer(text="放行", show_alert=False)
await self.restore_member(context, chat.id, user_id)
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|auth_kick"):
schedule.remove()
if isinstance(member_info, ChatMember):
await message.edit_text(
f"{member_info.user.mention_markdown_v2()} 被本群管理员放行", parse_mode=ParseMode.MARKDOWN_V2
)
logger.info(
"用户 %s[%s] 在群 %s[%s] 被 %s[%s] 放行",
member_info.user.full_name,
member_info.user.id,
chat.title,
chat.id,
user.full_name,
user.id,
)
else:
await message.edit_text(f"{member_info} 被本群管理员放行", parse_mode=ParseMode.MARKDOWN_V2)
logger.info(
"用户 %s 在群 %s[%s] 被 %s[%s] 管理放行", member_info, chat.title, chat.id, user.full_name, user.id
)
elif result == "kick":
await callback_query.answer(text="驱离", show_alert=False)
await context.bot.ban_chat_member(chat.id, user_id)
if isinstance(member_info, ChatMember):
await message.edit_text(
f"{self.mention_markdown(member_info.user.id)} 被本群管理员驱离", parse_mode=ParseMode.MARKDOWN_V2
)
logger.info(
"用户 %s[%s] 在群 %s[%s] 被 %s[%s] 被管理驱离",
member_info.user.full_name,
member_info.user.id,
chat.title,
chat.id,
user.full_name,
user.id,
)
else:
await message.edit_text(f"{member_info} 被本群管理员驱离", parse_mode=ParseMode.MARKDOWN_V2)
logger.info(
"用户 %s 在群 %s[%s] 被 %s[%s] 管理驱离", member_info, chat.title, chat.id, user.full_name, user.id
)
elif result == "unban":
await callback_query.answer(text="解除驱离", show_alert=False)
await self.restore_member(context, chat.id, user_id)
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|auth_kick"):
schedule.remove()
if isinstance(member_info, ChatMember):
await message.edit_text(
f"{member_info.user.mention_markdown_v2()} 被本群管理员解除封禁", parse_mode=ParseMode.MARKDOWN_V2
)
logger.info(
"用户 %s[%s] 在群 %s[%s] 被 %s[%s] 解除封禁",
member_info.user.full_name,
member_info.user.id,
chat.title,
chat.id,
user.full_name,
user.id,
)
else:
await message.edit_text(f"{member_info} 被本群管理员解除封禁", parse_mode=ParseMode.MARKDOWN_V2)
logger.info(
"用户 %s 在群 %s[%s] 被 %s[%s] 管理驱离", member_info, chat.title, chat.id, user.full_name, user.id
)
else:
logger.warning("auth 模块 admin 函数 发现未知命令 result[%s]", result)
await context.bot.send_message(chat.id, f"{config.notice.bot_name}这边收到了错误的消息!请检查详细日记!")
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|auth_kick"):
schedule.remove()
@handler(CallbackQueryHandler, pattern=r"^auth_challenge\|", block=False)
async def query(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
async def query_callback(callback_query_data: str) -> Tuple[int, bool, str, str]:
_data = callback_query_data.split("|")
_user_id = int(_data[1])
_question_id = int(_data[2])
_answer_id = int(_data[3])
_answer = await self.quiz_service.get_answer(_answer_id)
_question = await self.quiz_service.get_question(_question_id)
_result = _answer.is_correct
_answer_encode = _answer.text
_question_encode = _question.text
logger.debug(
"query_callback函数返回 user_id[%s] result[%s] \nquestion_encode[%s] answer_encode[%s]",
_user_id,
_result,
_question_encode,
_answer_encode,
)
return _user_id, _result, _question_encode, _answer_encode
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
chat = message.chat
user_id, result, question, answer = await query_callback(callback_query.data)
logger.info("用户 %s[%s] 在群 %s[%s] 点击Auth认证命令", user.full_name, user.id, chat.title, chat.id)
if user.id != user_id:
await callback_query.answer(text="这不是你的验证!\n" + self.user_mismatch, show_alert=True)
return
logger.info(
"用户 %s[%s] 在群 %s[%s] 认证结果为 %s",
user.full_name,
user.id,
chat.title,
chat.id,
"通过" if result else "失败",
)
if result:
buttons = [[InlineKeyboardButton("驱离", callback_data=f"auth_admin|kick|{user.id}")]]
await callback_query.answer(text="验证成功", show_alert=False)
await self.restore_member(context, chat.id, user_id)
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user.id}|auth_kick"):
schedule.remove()
text = (
f"{user.mention_markdown_v2()} 验证成功,向着星辰与深渊!\n"
f"问题:{escape_markdown(question, version=2)} \n"
f"回答:{escape_markdown(answer, version=2)}"
)
logger.info("用户 user_id[%s] 在群 %s[%s] 验证成功", user_id, chat.title, chat.id)
else:
buttons = [
[
InlineKeyboardButton("驱离", callback_data=f"auth_admin|kick|{user.id}"),
InlineKeyboardButton("撤回驱离", callback_data=f"auth_admin|unban|{user.id}"),
]
]
await callback_query.answer(text=f"验证失败,请在 {self.time_out} 秒后重试", show_alert=True)
await asyncio.sleep(3)
await context.bot.ban_chat_member(
chat_id=chat.id, user_id=user_id, until_date=int(time.time()) + self.kick_time
)
text = (
f"{user.mention_markdown_v2()} 验证失败,已经赶出提瓦特大陆!\n"
f"问题:{escape_markdown(question, version=2)} \n"
f"回答:{escape_markdown(answer, version=2)}"
)
logger.info("用户 user_id[%s] 在群 %s[%s] 验证失败", user_id, chat.title, chat.id)
try:
await message.edit_text(text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode=ParseMode.MARKDOWN_V2)
except BadRequest as exc:
if "are exactly the same as " in exc.message:
logger.warning("编辑消息发生异常,可能为用户点按多次键盘导致")
else:
raise exc
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user.id}|auth_kick"):
schedule.remove()
@handler.message(filters=filters.StatusUpdate.NEW_CHAT_MEMBERS, block=False)
async def new_mem(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
message = update.effective_message
chat = message.chat
if chat.id not in self.verify_groups:
return
for user in message.new_chat_members:
if user.id == context.bot.id:
return
logger.debug("用户 %s[%s] 加入群 %s[%s]", user.full_name, user.id, chat.title, chat.id)
await self.set_new_chat_members_message(user, message)
try:
await message.delete()
except BadRequest as exc:
logger.warning("无法删除 Chat Members Message [%s]", exc.message)
@handler.chat_member(chat_member_types=ChatMemberHandler.CHAT_MEMBER, block=False)
async def track_users(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
chat = update.effective_chat
if chat.id not in self.verify_groups:
return
new_chat_member = update.chat_member.new_chat_member
from_user = update.chat_member.from_user
user = new_chat_member.user
result = extract_status_change(update.chat_member)
if result is None:
return
was_member, is_member = result
if was_member and not is_member:
logger.info("用户 %s[%s] 退出群聊 %s[%s]", user.full_name, user.id, chat.title, chat.id)
return
if not was_member and is_member:
logger.info("用户 %s[%s] 尝试加入群 %s[%s]", user.full_name, user.id, chat.title, chat.id)
if user.is_bot:
return
chat_administrators = await ChatAdministrators.get_chat_administrators(self.cache, context, chat_id=chat.id)
if ChatAdministrators.is_admin(chat_administrators, from_user.id):
await chat.send_message(f"{config.notice.bot_name}检测到管理员邀请,自动放行了!")
return
question_id_list = await self.quiz_service.get_question_id_list()
if len(question_id_list) == 0:
await chat.send_message("旅行者!!!派蒙的问题清单你还没给我!!快去私聊我给我问题!")
return
try:
await chat.restrict_member(user_id=user.id, permissions=ChatPermissions(can_send_messages=False))
except BadRequest as exc:
if "Not enough rights" in exc.message:
logger.warning("%s[%s] 权限不够", chat.title, chat.id)
await chat.send_message(
f"{config.notice.bot_name}无法修改 {user.mention_html()} 的权限!请检查是否给{config.notice.bot_name}授权管理了",
parse_mode=ParseMode.HTML,
)
return
raise exc
question_id = random.choice(question_id_list) # nosec
question = await self.quiz_service.get_question(question_id)
buttons = [
[
InlineKeyboardButton(
answer.text,
callback_data=f"auth_challenge|{user.id}|{question.question_id}|{answer.answer_id}",
)
]
for answer in question.answers
]
random.shuffle(buttons)
buttons.append(
[
InlineKeyboardButton(
"放行",
callback_data=f"auth_admin|pass|{user.id}",
),
InlineKeyboardButton(
"驱离",
callback_data=f"auth_admin|kick|{user.id}",
),
]
)
reply_message = (
f"*欢迎 {user.mention_markdown_v2()} 来到「提瓦特」世界!* \n"
f"问题: {escape_markdown(question.text, version=2)} \n"
f"请在*{self.time_out}*秒内回答问题"
)
logger.debug(
"发送入群验证问题 %s[%s] \n%s[%s] 在 %s[%s]",
question.text,
question.question_id,
user.full_name,
user.id,
chat.title,
chat.id,
)
try:
question_message = await chat.send_message(
reply_message,
reply_markup=InlineKeyboardMarkup(buttons),
parse_mode=ParseMode.MARKDOWN_V2,
)
except BadRequest as exc:
await chat.send_message(
f"{config.notice.bot_name}分心了一下,不小心忘记你了,你只能先退出群再重新进来吧。"
)
raise exc
context.job_queue.run_once(
callback=self.kick_member_job,
when=self.time_out,
name=f"{chat.id}|{user.id}|auth_kick",
chat_id=chat.id,
user_id=user.id,
job_kwargs={"replace_existing": True, "id": f"{chat.id}|{user.id}|auth_kick"},
)
context.job_queue.run_once(
callback=self.clean_message_job,
when=self.time_out,
data=question_message.message_id,
name=f"{chat.id}|{user.id}|auth_clean_question_message",
chat_id=chat.id,
user_id=user.id,
job_kwargs={"replace_existing": True, "id": f"{chat.id}|{user.id}|auth_clean_question_message"},
)
if PYROGRAM_AVAILABLE and self.mtp:
new_chat_members_message = await self.get_new_chat_members_message(user, context)
try:
if new_chat_members_message:
if question_message.id - new_chat_members_message.id - 1:
message_ids = list(range(new_chat_members_message.id + 1, question_message.id))
else:
return
else:
message_ids = [question_message.id - 3, question_message.id]
messages_list = await self.mtp.get_messages(chat.id, message_ids=message_ids)
for find_message in messages_list:
if find_message.empty:
continue
if find_message.from_user and find_message.from_user.id == user.id:
await self.mtp.delete_messages(chat_id=chat.id, message_ids=find_message.id)
text: Optional[str] = None
if find_message.text and "@" in find_message.text:
text = f"{user.full_name} 由于加入群组后,在验证缝隙间发送了带有 @(Mention) 的消息,已被踢出群组,并加入了封禁列表。"
elif find_message.caption and "@" in find_message.caption:
text = f"{user.full_name} 由于加入群组后,在验证缝隙间发送了带有 @(Mention) 的消息,已被踢出群组,并加入了封禁列表。"
elif find_message.forward_from_chat:
text = f"{user.full_name} 由于加入群组后,在验证缝隙间发送了带有 Forward 的消息,已被踢出群组,并加入了封禁列表。"
if text is not None:
await context.bot.ban_chat_member(chat.id, user.id)
button = [
[InlineKeyboardButton("解除封禁", callback_data=f"auth_admin|pass|{user.id}")]
]
await question_message.edit_text(text, reply_markup=InlineKeyboardMarkup(button))
if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user.id}|auth_kick"):
schedule.remove()
logger.info(
"用户 %s[%s] 在群 %s[%s] 验证缝隙间发送消息 现已删除",
user.full_name,
user.id,
chat.title,
chat.id,
)
except BadRequest as exc:
logger.error("后验证处理中发生错误 %s", exc.message)
logger.exception(exc)
except MTPFloodWait:
logger.warning("调用 mtp 触发洪水限制")
except MTPBadRequest as exc:
logger.error("调用 mtp 请求错误")
logger.exception(exc)

View File

@ -1,155 +0,0 @@
import datetime
from asyncio import sleep
from typing import TYPE_CHECKING, List
from simnet.errors import (
TimedOut as SimnetTimedOut,
BadRequest as SimnetBadRequest,
InvalidCookies,
)
from telegram.constants import ParseMode
from telegram.error import BadRequest, Forbidden
from core.plugin import Plugin, job
from core.services.history_data.services import HistoryDataAbyssServices, HistoryDataLedgerServices
from gram_core.basemodel import RegionEnum
from gram_core.plugin import handler
from gram_core.services.cookies import CookiesService
from gram_core.services.cookies.models import CookiesStatusEnum
from plugins.genshin.abyss import AbyssPlugin
from plugins.genshin.ledger import LedgerPlugin
from plugins.tools.genshin import GenshinHelper, PlayerNotFoundError, CookiesNotFoundError
from utils.log import logger
if TYPE_CHECKING:
from telegram import Update
from telegram.ext import ContextTypes
from simnet import GenshinClient
REGION = [RegionEnum.HYPERION, RegionEnum.HOYOLAB]
NOTICE_TEXT = """#### %s更新 ####
时间%s (UTC+8)
UID: %s
结果: 新的%s已保存可通过命令回顾"""
class RefreshHistoryJob(Plugin):
"""历史记录定时刷新"""
def __init__(
self,
cookies: CookiesService,
genshin_helper: GenshinHelper,
history_abyss: HistoryDataAbyssServices,
history_ledger: HistoryDataLedgerServices,
):
self.cookies = cookies
self.genshin_helper = genshin_helper
self.history_data_abyss = history_abyss
self.history_data_ledger = history_ledger
@staticmethod
async def send_notice(context: "ContextTypes.DEFAULT_TYPE", user_id: int, notice_text: str):
try:
await context.bot.send_message(user_id, notice_text, parse_mode=ParseMode.HTML)
except (BadRequest, Forbidden) as exc:
logger.error("执行自动刷新历史记录时发生错误 user_id[%s] Message[%s]", user_id, exc.message)
except Exception as exc:
logger.error("执行自动刷新历史记录时发生错误 user_id[%s]", user_id, exc_info=exc)
async def save_abyss_data(self, client: "GenshinClient") -> bool:
uid = client.player_id
abyss_data = await client.get_genshin_spiral_abyss(uid, previous=False, lang="zh-cn")
avatars = await client.get_genshin_characters(uid, lang="zh-cn")
avatar_data = {i.id: i.constellation for i in avatars}
if abyss_data.unlocked and abyss_data.ranks and abyss_data.ranks.most_kills:
return await AbyssPlugin.save_abyss_data(self.history_data_abyss, uid, abyss_data, avatar_data)
return False
async def send_abyss_notice(self, context: "ContextTypes.DEFAULT_TYPE", user_id: int, uid: int):
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
notice_text = NOTICE_TEXT % ("深渊历史记录", now, uid, "挑战记录")
await self.send_notice(context, user_id, notice_text)
async def _save_ledger_data(self, client: "GenshinClient", month: int) -> bool:
diary_info = await client.get_genshin_diary(client.player_id, month=month)
return await LedgerPlugin.save_ledger_data(self.history_data_ledger, client.player_id, diary_info)
@staticmethod
def get_ledger_months() -> List[int]:
now = datetime.datetime.now()
now_time = (now - datetime.timedelta(days=1)) if now.day == 1 and now.hour <= 4 else now
months = []
last_month = now_time.replace(day=1) - datetime.timedelta(days=1)
months.append(last_month.month)
last_month = last_month.replace(day=1) - datetime.timedelta(days=1)
months.append(last_month.month)
return months
async def save_ledger_data(self, client: "GenshinClient") -> bool:
months = self.get_ledger_months()
ok = False
for month in months:
if await self._save_ledger_data(client, month):
ok = True
return ok
async def send_ledger_notice(self, context: "ContextTypes.DEFAULT_TYPE", user_id: int, uid: int):
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
notice_text = NOTICE_TEXT % ("旅行札记历史记录", now, uid, "旅行札记历史记录")
await self.send_notice(context, user_id, notice_text)
@handler.command(command="remove_same_history", block=False, admin=True)
async def remove_same_history(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE"):
user = update.effective_user
logger.info("用户 %s[%s] remove_same_history 命令请求", user.full_name, user.id)
message = update.effective_message
reply = await message.reply_text("正在执行移除相同数据历史记录任务,请稍后...")
text = "移除相同数据历史记录任务完成\n"
num1 = await self.history_data_abyss.remove_same_data()
text += f"深渊数据移除数量:{num1}\n"
num2 = await self.history_data_ledger.remove_same_data()
text += f"旅行札记数据移除数量:{num2}\n"
await reply.edit_text(text)
@handler.command(command="refresh_all_history", block=False, admin=True)
async def refresh_all_history(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
user = update.effective_user
logger.info("用户 %s[%s] refresh_all_history 命令请求", user.full_name, user.id)
message = update.effective_message
reply = await message.reply_text("正在执行刷新历史记录任务,请稍后...")
await self.daily_refresh_history(context)
await reply.edit_text("全部账号刷新历史记录任务完成")
@job.run_daily(time=datetime.time(hour=6, minute=1, second=0), name="RefreshHistoryJob")
async def daily_refresh_history(self, context: "ContextTypes.DEFAULT_TYPE"):
logger.info("正在执行每日刷新历史记录任务")
for database_region in REGION:
for cookie_model in await self.cookies.get_all(
region=database_region, status=CookiesStatusEnum.STATUS_SUCCESS
):
user_id = cookie_model.user_id
try:
async with self.genshin_helper.genshin(user_id) as client:
if await self.save_abyss_data(client):
await self.send_abyss_notice(context, user_id, client.player_id)
if await self.save_ledger_data(client):
await self.send_ledger_notice(context, user_id, client.player_id)
except (InvalidCookies, PlayerNotFoundError, CookiesNotFoundError):
continue
except SimnetBadRequest as exc:
logger.warning(
"用户 user_id[%s] 请求历史记录失败 [%s]%s", user_id, exc.ret_code, exc.original or exc.message
)
continue
except SimnetTimedOut:
logger.info("用户 user_id[%s] 请求历史记录超时", user_id)
continue
except Exception as exc:
logger.error("执行自动刷新历史记录时发生错误 user_id[%s]", user_id, exc_info=exc)
continue
await sleep(1)
logger.success("执行每日刷新历史记录任务完成")

View File

@ -2,7 +2,7 @@ import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from core.plugin import Plugin, job from core.plugin import Plugin, job
from plugins.genshin.sign import SignSystem from plugins.zzz.sign import SignSystem
from plugins.tools.sign import SignJobType from plugins.tools.sign import SignJobType
from utils.log import logger from utils.log import logger

View File

@ -25,6 +25,7 @@ from telegram.helpers import create_deep_linked_url
from core.config import config from core.config import config
from core.plugin import Plugin, error_handler from core.plugin import Plugin, error_handler
from gram_core.services.cookies.error import CookieServiceError as PublicCookieServiceError
from gram_core.services.players.error import PlayerNotFoundError from gram_core.services.players.error import PlayerNotFoundError
from modules.apihelper.error import APIHelperException, APIHelperTimedOut, ResponseException, ReturnCodeError from modules.apihelper.error import APIHelperException, APIHelperTimedOut, ResponseException, ReturnCodeError
from modules.errorpush import ( from modules.errorpush import (
@ -154,7 +155,7 @@ class ErrorHandler(Plugin):
elif exc.retcode == 10103: elif exc.retcode == 10103:
notice = ( notice = (
self.ERROR_MSG_PREFIX self.ERROR_MSG_PREFIX
+ "Cookie 有效,但没有绑定到游戏帐户,请尝试登录通行证,在账号管理里面选择账号游戏信息,将原神设置为默认角色。" + "Cookie 有效,但没有绑定到游戏帐户,请尝试登录通行证,在账号管理里面选择账号游戏信息,将绝区零设置为默认角色。"
) )
else: else:
logger.error("未知Cookie错误", exc_info=exc) logger.error("未知Cookie错误", exc_info=exc)
@ -271,6 +272,13 @@ class ErrorHandler(Plugin):
self.create_notice_task(update, context, config.notice.user_not_found) self.create_notice_task(update, context, config.notice.user_not_found)
raise ApplicationHandlerStop raise ApplicationHandlerStop
@error_handler()
async def process_public_cookies(self, update: object, context: CallbackContext):
if not isinstance(context.error, PublicCookieServiceError) or not isinstance(update, Update):
return
self.create_notice_task(update, context, "公共Cookies池已经耗尽请稍后重试或者绑定账号")
raise ApplicationHandlerStop
@error_handler(block=False) @error_handler(block=False)
async def process_z_error(self, update: object, context: CallbackContext) -> None: async def process_z_error(self, update: object, context: CallbackContext) -> None:
# 必须 `process_` 加上 `z` 保证该函数最后一个注册 # 必须 `process_` 加上 `z` 保证该函数最后一个注册

View File

@ -1,186 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from simnet import Region
from simnet.client.routes import Route
from simnet.errors import BadRequest as SimnetBadRequest, RegionNotSupported, InvalidCookies, TimedOut as SimnetTimedOut
from simnet.utils.player import recognize_genshin_game_biz, recognize_genshin_server
from sqlalchemy.orm.exc import StaleDataError
from telegram.constants import ParseMode
from telegram.error import BadRequest, Forbidden
from core.plugin import Plugin
from core.services.task.models import TaskStatusEnum
from core.services.task.services import TaskCardServices
from metadata.shortname import roleToId
from modules.apihelper.client.components.calendar import Calendar
from plugins.tools.genshin import GenshinHelper, PlayerNotFoundError, CookiesNotFoundError
from utils.log import logger
if TYPE_CHECKING:
from simnet import GenshinClient
from telegram.ext import ContextTypes
BIRTHDAY_URL = Route(
"https://hk4e-api.mihoyo.com/event/birthdaystar/account/post_my_draw",
)
def rm_starting_str(string, starting):
"""Remove the starting character from a string."""
while string[0] == str(starting):
string = string[1:]
return string
class BirthdayCardNoBirthdayError(Exception):
pass
class BirthdayCardAlreadyClaimedError(Exception):
pass
class BirthdayCardSystem(Plugin):
def __init__(
self,
card_service: TaskCardServices,
genshin_helper: GenshinHelper,
):
self.birthday_list = {}
self.card_service = card_service
self.genshin_helper = genshin_helper
async def initialize(self):
self.birthday_list = await Calendar.async_gen_birthday_list()
self.birthday_list.get("6_1", []).append("派蒙")
@property
def key(self):
return (
rm_starting_str(datetime.now().strftime("%m"), "0")
+ "_"
+ rm_starting_str(datetime.now().strftime("%d"), "0")
)
def get_today_birthday(self) -> List[str]:
key = self.key
return (self.birthday_list.get(key, [])).copy()
@staticmethod
def role_to_id(name: str) -> Optional[int]:
if name == "派蒙":
return -1
return roleToId(name)
@staticmethod
async def get_card(client: "GenshinClient", role_id: int) -> None:
"""领取画片"""
url = BIRTHDAY_URL.get_url()
params = {
"game_biz": recognize_genshin_game_biz(client.player_id),
"lang": "zh-cn",
"badge_uid": client.player_id,
"badge_region": recognize_genshin_server(client.player_id),
"activity_id": "20220301153521",
}
json = {
"role_id": role_id,
}
try:
await client.request_lab(url, method="POST", params=params, data=json)
except SimnetBadRequest as e:
if e.retcode == -512008:
raise BirthdayCardNoBirthdayError from e # 未过生日
if e.retcode == -512009:
raise BirthdayCardAlreadyClaimedError from e # 已领取过
raise e
async def start_get_card(
self,
client: "GenshinClient",
) -> str:
if client.region == Region.OVERSEAS:
raise RegionNotSupported
today_list = self.get_today_birthday()
if not today_list:
raise BirthdayCardNoBirthdayError
game_biz = recognize_genshin_game_biz(client.player_id)
region = recognize_genshin_server(client.player_id)
await client.get_hk4e_token_by_cookie_token(game_biz, region)
for name in today_list.copy():
if role_id := self.role_to_id(name):
try:
await self.get_card(client, role_id)
except BirthdayCardAlreadyClaimedError:
today_list.remove(name)
if today_list:
text = f"成功领取了 {''.join(today_list)} 的生日画片~"
else:
raise BirthdayCardAlreadyClaimedError
return text
async def do_get_card_job(self, context: "ContextTypes.DEFAULT_TYPE"):
if not self.get_today_birthday():
logger.info("今天没有角色过生日,跳过自动领取生日画片")
return
include_status: List[TaskStatusEnum] = [
TaskStatusEnum.STATUS_SUCCESS,
TaskStatusEnum.TIMEOUT_ERROR,
]
task_list = await self.card_service.get_all()
for task_db in task_list:
if task_db.status not in include_status:
continue
user_id = task_db.user_id
try:
async with self.genshin_helper.genshin(user_id) as client:
text = await self.start_get_card(client)
except InvalidCookies:
text = "自动领取生日画片执行失败Cookie无效"
task_db.status = TaskStatusEnum.INVALID_COOKIES
except BirthdayCardAlreadyClaimedError:
text = "今天旅行者已经领取过了~"
task_db.status = TaskStatusEnum.ALREADY_CLAIMED
except SimnetBadRequest as exc:
text = f"自动领取生日画片执行失败API返回信息为 {str(exc)}"
task_db.status = TaskStatusEnum.GENSHIN_EXCEPTION
except SimnetTimedOut:
text = "领取失败了呜呜呜 ~ 服务器连接超时 服务器熟啦 ~ "
task_db.status = TaskStatusEnum.TIMEOUT_ERROR
except PlayerNotFoundError:
logger.info("用户 user_id[%s] 玩家不存在 关闭并移除自动领取生日画片", user_id)
await self.card_service.remove(task_db)
continue
except CookiesNotFoundError:
logger.info("用户 user_id[%s] cookie 不存在 关闭并移除自动领取生日画片", user_id)
await self.card_service.remove(task_db)
continue
except RegionNotSupported:
logger.info("用户 user_id[%s] 不支持的服务器 关闭并移除自动领取生日画片", user_id)
await self.card_service.remove(task_db)
continue
except Exception as exc:
logger.error("执行自动领取生日画片时发生错误 user_id[%s]", user_id, exc_info=exc)
text = "自动领取生日画片失败了呜呜呜 ~ 执行自动领取生日画片时发生错误"
else:
task_db.status = TaskStatusEnum.STATUS_SUCCESS
if task_db.chat_id < 0:
text = f'<a href="tg://user?id={task_db.user_id}">NOTICE {task_db.user_id}</a>\n\n{text}'
try:
await context.bot.send_message(task_db.chat_id, text, parse_mode=ParseMode.HTML)
except BadRequest as exc:
logger.error("执行自动领取生日画片时发生错误 user_id[%s] Message[%s]", user_id, exc.message)
task_db.status = TaskStatusEnum.BAD_REQUEST
except Forbidden as exc:
logger.error("执行自动领取生日画片时发生错误 user_id[%s] message[%s]", user_id, exc.message)
task_db.status = TaskStatusEnum.FORBIDDEN
except Exception as exc:
logger.error("执行自动领取生日画片时发生错误 user_id[%s]", user_id, exc_info=exc)
continue
else:
task_db.status = TaskStatusEnum.STATUS_SUCCESS
try:
await self.card_service.update(task_db)
except StaleDataError:
logger.warning("用户 user_id[%s] 自动领取生日画片数据过期,跳过更新数据", user_id)

View File

@ -59,7 +59,7 @@ class ChallengeSystem(Plugin):
raise ChallengeSystemException("无需验证") raise ChallengeSystemException("无需验证")
if need_verify: if need_verify:
try: try:
await client.get_genshin_notes() await client.get_zzz_notes()
except NeedChallenge: except NeedChallenge:
pass pass
else: else:

View File

@ -3,22 +3,22 @@ from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Union from typing import TYPE_CHECKING, List, Optional, Union
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from simnet import Region
from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, TimedOut as SimnetTimedOut from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, TimedOut as SimnetTimedOut
from simnet.models.zzz.chronicle.notes import ZZZNoteVhsSaleState
from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.error import BadRequest, Forbidden from telegram.error import BadRequest, Forbidden
from core.plugin import Plugin from core.plugin import Plugin
from core.services.task.models import Task as TaskUser, TaskStatusEnum from core.services.task.models import Task as TaskUser, TaskStatusEnum
from core.services.task.services import TaskResinServices, TaskRealmServices, TaskExpeditionServices, TaskDailyServices from core.services.task.services import TaskResinServices, TaskExpeditionServices, TaskDailyServices
from gram_core.plugin.methods.migrate_data import IMigrateData, MigrateDataException from gram_core.plugin.methods.migrate_data import IMigrateData, MigrateDataException
from plugins.tools.genshin import GenshinHelper, PlayerNotFoundError, CookiesNotFoundError from plugins.tools.genshin import GenshinHelper, PlayerNotFoundError, CookiesNotFoundError
from utils.log import logger from utils.log import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from simnet import GenshinClient from simnet import ZZZClient
from simnet.models.genshin.chronicle.notes import Notes, NotesWidget from simnet.models.zzz.chronicle.notes import ZZZNote
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from gram_core.services.task.services import TaskServices from gram_core.services.task.services import TaskServices
@ -33,17 +33,7 @@ class ResinData(TaskDataBase):
@validator("notice_num") @validator("notice_num")
def notice_num_validator(cls, v): def notice_num_validator(cls, v):
if v < 60 or v > 200: if v < 60 or v > 200:
raise ValueError("树脂提醒数值必须在 60 ~ 200 之间") raise ValueError("电量提醒数值必须在 60 ~ 200 之间")
return v
class RealmData(TaskDataBase):
notice_num: Optional[int] = 2000
@validator("notice_num")
def notice_num_validator(cls, v):
if v < 100 or v > 2400:
raise ValueError("洞天宝钱提醒数值必须在 100 ~ 2400 之间")
return v return v
@ -63,7 +53,6 @@ class DailyData(TaskDataBase):
class WebAppData(BaseModel): class WebAppData(BaseModel):
resin: Optional[ResinData] resin: Optional[ResinData]
realm: Optional[RealmData]
expedition: Optional[ExpeditionData] expedition: Optional[ExpeditionData]
daily: Optional[DailyData] daily: Optional[DailyData]
@ -73,17 +62,14 @@ class DailyNoteTaskUser:
self, self,
user_id: int, user_id: int,
resin_db: Optional[TaskUser] = None, resin_db: Optional[TaskUser] = None,
realm_db: Optional[TaskUser] = None,
expedition_db: Optional[TaskUser] = None, expedition_db: Optional[TaskUser] = None,
daily_db: Optional[TaskUser] = None, daily_db: Optional[TaskUser] = None,
): ):
self.user_id = user_id self.user_id = user_id
self.resin_db = resin_db self.resin_db = resin_db
self.realm_db = realm_db
self.expedition_db = expedition_db self.expedition_db = expedition_db
self.daily_db = daily_db self.daily_db = daily_db
self.resin = ResinData(**self.resin_db.data) if self.resin_db else None self.resin = ResinData(**self.resin_db.data) if self.resin_db else None
self.realm = RealmData(**self.realm_db.data) if self.realm_db else None
self.expedition = ExpeditionData(**self.expedition_db.data) if self.expedition_db else None self.expedition = ExpeditionData(**self.expedition_db.data) if self.expedition_db else None
self.daily = DailyData(**self.daily_db.data) if self.daily_db else None self.daily = DailyData(**self.daily_db.data) if self.daily_db else None
@ -92,7 +78,6 @@ class DailyNoteTaskUser:
return max( return max(
[ [
self.resin_db.status if self.resin_db else TaskStatusEnum.STATUS_SUCCESS, self.resin_db.status if self.resin_db else TaskStatusEnum.STATUS_SUCCESS,
self.realm_db.status if self.realm_db else TaskStatusEnum.STATUS_SUCCESS,
self.expedition_db.status if self.expedition_db else TaskStatusEnum.STATUS_SUCCESS, self.expedition_db.status if self.expedition_db else TaskStatusEnum.STATUS_SUCCESS,
self.daily_db.status if self.daily_db else TaskStatusEnum.STATUS_SUCCESS, self.daily_db.status if self.daily_db else TaskStatusEnum.STATUS_SUCCESS,
] ]
@ -102,8 +87,6 @@ class DailyNoteTaskUser:
def status(self, value: TaskStatusEnum): def status(self, value: TaskStatusEnum):
if self.resin_db: if self.resin_db:
self.resin_db.status = value self.resin_db.status = value
if self.realm_db:
self.realm_db.status = value
if self.expedition_db: if self.expedition_db:
self.expedition_db.status = value self.expedition_db.status = value
if self.daily: if self.daily:
@ -125,7 +108,6 @@ class DailyNoteTaskUser:
( (
WebAppData( WebAppData(
resin=self.set_model_noticed(self.resin) if self.resin else None, resin=self.set_model_noticed(self.resin) if self.resin else None,
realm=self.set_model_noticed(self.realm) if self.realm else None,
expedition=self.set_model_noticed(self.expedition) if self.expedition else None, expedition=self.set_model_noticed(self.expedition) if self.expedition else None,
daily=self.set_model_noticed(self.daily) if self.daily else None, daily=self.set_model_noticed(self.daily) if self.daily else None,
).json() ).json()
@ -135,8 +117,6 @@ class DailyNoteTaskUser:
def save(self): def save(self):
if self.resin_db: if self.resin_db:
self.resin_db.data = self.resin.dict() self.resin_db.data = self.resin.dict()
if self.realm_db:
self.realm_db.data = self.realm.dict()
if self.expedition_db: if self.expedition_db:
self.expedition_db.data = self.expedition.dict() self.expedition_db.data = self.expedition.dict()
if self.daily_db: if self.daily_db:
@ -148,38 +128,34 @@ class DailyNoteSystem(Plugin):
self, self,
genshin_helper: GenshinHelper, genshin_helper: GenshinHelper,
resin_service: TaskResinServices, resin_service: TaskResinServices,
realm_service: TaskRealmServices,
expedition_service: TaskExpeditionServices, expedition_service: TaskExpeditionServices,
daily_service: TaskDailyServices, daily_service: TaskDailyServices,
): ):
self.genshin_helper = genshin_helper self.genshin_helper = genshin_helper
self.resin_service = resin_service self.resin_service = resin_service
self.realm_service = realm_service
self.expedition_service = expedition_service self.expedition_service = expedition_service
self.daily_service = daily_service self.daily_service = daily_service
async def get_single_task_user(self, user_id: int) -> DailyNoteTaskUser: async def get_single_task_user(self, user_id: int) -> DailyNoteTaskUser:
resin_db = await self.resin_service.get_by_user_id(user_id) resin_db = await self.resin_service.get_by_user_id(user_id)
realm_db = await self.realm_service.get_by_user_id(user_id)
expedition_db = await self.expedition_service.get_by_user_id(user_id) expedition_db = await self.expedition_service.get_by_user_id(user_id)
daily_db = await self.daily_service.get_by_user_id(user_id) daily_db = await self.daily_service.get_by_user_id(user_id)
return DailyNoteTaskUser( return DailyNoteTaskUser(
user_id=user_id, user_id=user_id,
resin_db=resin_db, resin_db=resin_db,
realm_db=realm_db,
expedition_db=expedition_db, expedition_db=expedition_db,
daily_db=daily_db, daily_db=daily_db,
) )
@staticmethod @staticmethod
def get_resin_notice(user: DailyNoteTaskUser, notes: Union["Notes", "NotesWidget"]) -> str: def get_resin_notice(user: DailyNoteTaskUser, notes: Union["ZZZNote"]) -> str:
notice = None notice = None
if user.resin_db and notes.max_resin > 0: if user.resin_db and notes.max_stamina > 0:
if notes.current_resin >= user.resin.notice_num: if notes.current_stamina >= user.resin.notice_num:
if not user.resin.noticed: if not user.resin.noticed:
notice = ( notice = (
f"### 树脂提示 ####\n\n当前树脂{notes.current_resin} / {notes.max_resin} ,记得使用哦~\n" f"### 电量提示 ####\n\n当前电量{notes.current_stamina} / {notes.max_stamina} ,记得使用哦~\n"
f"预计全部恢复完成:{notes.resin_recovery_time.strftime('%Y-%m-%d %H:%M')}" f"预计全部恢复完成:{notes.stamina_recover_time.strftime('%Y-%m-%d %H:%M')}"
) )
user.resin.noticed = True user.resin.noticed = True
else: else:
@ -187,46 +163,28 @@ class DailyNoteSystem(Plugin):
return notice return notice
@staticmethod @staticmethod
def get_realm_notice(user: DailyNoteTaskUser, notes: Union["Notes", "NotesWidget"]) -> str: def get_expedition_notice(user: DailyNoteTaskUser, notes: Union["ZZZNote"]) -> str:
notice = None notice = None
if user.realm_db and notes.max_realm_currency > 0: if user.expedition_db and notes.vhs_sale.sale_state:
if notes.current_realm_currency >= user.realm.notice_num: all_finished = notes.vhs_sale.sale_state == ZZZNoteVhsSaleState.DONE
if not user.realm.noticed:
notice = (
f"### 洞天宝钱提示 ####\n\n"
f"当前存储为 {notes.current_realm_currency} / {notes.max_realm_currency} ,记得领取哦~"
)
user.realm.noticed = True
else:
user.realm.noticed = False
return notice
@staticmethod
def get_expedition_notice(user: DailyNoteTaskUser, notes: Union["Notes", "NotesWidget"]) -> str:
notice = None
if user.expedition_db and len(notes.expeditions) > 0:
all_finished = all(i.status == "Finished" for i in notes.expeditions)
if all_finished: if all_finished:
if not user.expedition.noticed: if not user.expedition.noticed:
notice = "### 探索派遣提示 ####\n\n所有探索派遣已完成,记得重新派遣哦~" notice = "### 录像店经营提示 ####\n\n录像店经营已完成,记得重新上架哦~"
user.expedition.noticed = True user.expedition.noticed = True
else: else:
user.expedition.noticed = False user.expedition.noticed = False
return notice return notice
@staticmethod @staticmethod
def get_daily_notice(user: DailyNoteTaskUser, notes: Union["Notes", "NotesWidget"]) -> str: def get_daily_notice(user: DailyNoteTaskUser, notes: Union["ZZZNote"]) -> str:
notice = None notice = None
now_hour = datetime.now().hour now_hour = datetime.now().hour
if user.daily_db: if user.daily_db:
if now_hour == user.daily.notice_hour: if now_hour == user.daily.notice_hour:
if (notes.completed_commissions != notes.max_commissions or (not notes.claimed_commission_reward)) and ( if (notes.current_train_score != notes.max_train_score) and (not user.daily.noticed):
not user.daily.noticed
):
notice_ = "已领取奖励" if notes.claimed_commission_reward else "未领取奖励"
notice = ( notice = (
f"### 每日任务提示 ####\n\n" f"### 每日任务提示 ####\n\n"
f"当前进度为 {notes.completed_commissions} / {notes.max_commissions} ({notice_}) ,记得完成哦~" f"当前进度为 {notes.current_train_score} / {notes.max_train_score} ,记得完成哦~"
) )
user.daily.noticed = True user.daily.noticed = True
else: else:
@ -235,18 +193,14 @@ class DailyNoteSystem(Plugin):
@staticmethod @staticmethod
async def start_get_notes( async def start_get_notes(
client: "GenshinClient", client: "ZZZClient",
user: DailyNoteTaskUser = None, user: DailyNoteTaskUser = None,
) -> List[str]: ) -> List[str]:
if client.region == Region.OVERSEAS: notes = await client.get_zzz_notes()
notes = await client.get_genshin_notes()
else:
notes = await client.get_genshin_notes_by_stoken()
if not user: if not user:
return [] return []
notices = [ notices = [
DailyNoteSystem.get_resin_notice(user, notes), DailyNoteSystem.get_resin_notice(user, notes),
DailyNoteSystem.get_realm_notice(user, notes),
DailyNoteSystem.get_expedition_notice(user, notes), DailyNoteSystem.get_expedition_notice(user, notes),
DailyNoteSystem.get_daily_notice(user, notes), DailyNoteSystem.get_daily_notice(user, notes),
] ]
@ -255,14 +209,11 @@ class DailyNoteSystem(Plugin):
async def get_all_task_users(self) -> List[DailyNoteTaskUser]: async def get_all_task_users(self) -> List[DailyNoteTaskUser]:
resin_list = await self.resin_service.get_all() resin_list = await self.resin_service.get_all()
realm_list = await self.realm_service.get_all()
expedition_list = await self.expedition_service.get_all() expedition_list = await self.expedition_service.get_all()
daily_list = await self.daily_service.get_all() daily_list = await self.daily_service.get_all()
user_list = set() user_list = set()
for i in resin_list: for i in resin_list:
user_list.add(i.user_id) user_list.add(i.user_id)
for i in realm_list:
user_list.add(i.user_id)
for i in expedition_list: for i in expedition_list:
user_list.add(i.user_id) user_list.add(i.user_id)
for i in daily_list: for i in daily_list:
@ -271,7 +222,6 @@ class DailyNoteSystem(Plugin):
DailyNoteTaskUser( DailyNoteTaskUser(
user_id=i, user_id=i,
resin_db=next((x for x in resin_list if x.user_id == i), None), resin_db=next((x for x in resin_list if x.user_id == i), None),
realm_db=next((x for x in realm_list if x.user_id == i), None),
expedition_db=next((x for x in expedition_list if x.user_id == i), None), expedition_db=next((x for x in expedition_list if x.user_id == i), None),
daily_db=next((x for x in daily_list if x.user_id == i), None), daily_db=next((x for x in daily_list if x.user_id == i), None),
) )
@ -281,8 +231,6 @@ class DailyNoteSystem(Plugin):
async def remove_task_user(self, user: DailyNoteTaskUser): async def remove_task_user(self, user: DailyNoteTaskUser):
if user.resin_db: if user.resin_db:
await self.resin_service.remove(user.resin_db) await self.resin_service.remove(user.resin_db)
if user.realm_db:
await self.realm_service.remove(user.realm_db)
if user.expedition_db: if user.expedition_db:
await self.expedition_service.remove(user.expedition_db) await self.expedition_service.remove(user.expedition_db)
if user.daily_db: if user.daily_db:
@ -293,17 +241,12 @@ class DailyNoteSystem(Plugin):
try: try:
await self.resin_service.update(user.resin_db) await self.resin_service.update(user.resin_db)
except StaleDataError: except StaleDataError:
logger.warning("用户 user_id[%s] 自动便签提醒 - 树脂数据过期,跳过更新数据", user.user_id) logger.warning("用户 user_id[%s] 自动便签提醒 - 电量数据过期,跳过更新数据", user.user_id)
if user.realm_db:
try:
await self.realm_service.update(user.realm_db)
except StaleDataError:
logger.warning("用户 user_id[%s] 自动便签提醒 - 洞天宝钱数据过期,跳过更新数据", user.user_id)
if user.expedition_db: if user.expedition_db:
try: try:
await self.expedition_service.update(user.expedition_db) await self.expedition_service.update(user.expedition_db)
except StaleDataError: except StaleDataError:
logger.warning("用户 user_id[%s] 自动便签提醒 - 探索派遣数据过期,跳过更新数据", user.user_id) logger.warning("用户 user_id[%s] 自动便签提醒 - 录像店经营数据过期,跳过更新数据", user.user_id)
if user.daily_db: if user.daily_db:
try: try:
await self.daily_service.update(user.daily_db) await self.daily_service.update(user.daily_db)
@ -315,8 +258,6 @@ class DailyNoteSystem(Plugin):
need_verify = False need_verify = False
if web_config.resin and web_config.resin.noticed: if web_config.resin and web_config.resin.noticed:
need_verify = True need_verify = True
if web_config.realm and web_config.realm.noticed:
need_verify = True
if web_config.expedition and web_config.expedition.noticed: if web_config.expedition and web_config.expedition.noticed:
need_verify = True need_verify = True
if web_config.daily and web_config.daily.noticed: if web_config.daily and web_config.daily.noticed:
@ -344,26 +285,6 @@ class DailyNoteSystem(Plugin):
user.resin_db = None user.resin_db = None
user.resin = None user.resin = None
async def import_web_config_realm(self, user: DailyNoteTaskUser, web_config: WebAppData):
user_id = user.user_id
if web_config.realm.noticed:
if not user.realm_db:
realm = self.realm_service.create(
user_id,
user_id,
status=TaskStatusEnum.STATUS_SUCCESS,
data=RealmData(notice_num=web_config.realm.notice_num).dict(),
)
await self.realm_service.add(realm)
else:
user.realm.notice_num = web_config.realm.notice_num
user.realm.noticed = False
else:
if user.realm_db:
await self.realm_service.remove(user.realm_db)
user.realm_db = None
user.realm = None
async def import_web_config_expedition(self, user: DailyNoteTaskUser, web_config: WebAppData): async def import_web_config_expedition(self, user: DailyNoteTaskUser, web_config: WebAppData):
user_id = user.user_id user_id = user.user_id
if web_config.expedition.noticed: if web_config.expedition.noticed:
@ -408,8 +329,6 @@ class DailyNoteSystem(Plugin):
user = await self.get_single_task_user(user_id) user = await self.get_single_task_user(user_id)
if web_config.resin: if web_config.resin:
await self.import_web_config_resin(user, web_config) await self.import_web_config_resin(user, web_config)
if web_config.realm:
await self.import_web_config_realm(user, web_config)
if web_config.expedition: if web_config.expedition:
await self.import_web_config_expedition(user, web_config) await self.import_web_config_expedition(user, web_config)
if web_config.daily: if web_config.daily:
@ -453,9 +372,7 @@ class DailyNoteSystem(Plugin):
text = "获取便签失败了呜呜呜 ~ 执行自动便签提醒时发生错误" text = "获取便签失败了呜呜呜 ~ 执行自动便签提醒时发生错误"
else: else:
task_db.status = TaskStatusEnum.STATUS_SUCCESS task_db.status = TaskStatusEnum.STATUS_SUCCESS
for idx, task_user_db in enumerate( for idx, task_user_db in enumerate([task_db.resin_db, task_db.expedition_db, task_db.daily_db]):
[task_db.resin_db, task_db.realm_db, task_db.expedition_db, task_db.daily_db]
):
if task_user_db is None: if task_user_db is None:
continue continue
notice_text = text[idx] if isinstance(text, list) else text notice_text = text[idx] if isinstance(text, list) else text

View File

@ -6,7 +6,7 @@ from typing import Optional
from typing import TYPE_CHECKING, Union from typing import TYPE_CHECKING, Union
from pydantic import ValidationError from pydantic import ValidationError
from simnet import GenshinClient, Region from simnet import ZZZClient, Region
from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, NetworkError, CookieException, NeedChallenge from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, NetworkError, CookieException, NeedChallenge
from simnet.models.genshin.calculator import CalculatorCharacterDetails from simnet.models.genshin.calculator import CalculatorCharacterDetails
from simnet.models.genshin.chronicle.characters import Character from simnet.models.genshin.chronicle.characters import Character
@ -158,7 +158,7 @@ class CharacterDetails(Plugin):
return None return None
async def get_character_details( async def get_character_details(
self, client: "GenshinClient", character: "Union[int,Character]" self, client: "ZZZClient", character: "Union[int,Character]"
) -> Optional["CalculatorCharacterDetails"]: ) -> Optional["CalculatorCharacterDetails"]:
"""缓存 character_details 并定时对其进行数据存储 当遇到 Too Many Requests 可以获取以前的数据""" """缓存 character_details 并定时对其进行数据存储 当遇到 Too Many Requests 可以获取以前的数据"""
uid = client.player_id uid = client.player_id
@ -220,7 +220,7 @@ class GenshinHelper(Plugin):
@asynccontextmanager @asynccontextmanager
async def genshin( # skipcq: PY-R1000 # async def genshin( # skipcq: PY-R1000 #
self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0 self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0
) -> GenshinClient: ) -> ZZZClient:
player = await self.players_service.get_player(user_id, region, player_id, offset) player = await self.players_service.get_player(user_id, region, player_id, offset)
if player is None: if player is None:
raise PlayerNotFoundError(user_id) raise PlayerNotFoundError(user_id)
@ -246,7 +246,7 @@ class GenshinHelper(Plugin):
device_id = devices.device_id device_id = devices.device_id
device_fp = devices.device_fp device_fp = devices.device_fp
async with GenshinClient( async with ZZZClient(
cookies, cookies,
region=region, region=region,
account_id=player.account_id, account_id=player.account_id,
@ -308,7 +308,7 @@ class GenshinHelper(Plugin):
async def get_genshin_client( async def get_genshin_client(
self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0 self, user_id: int, region: Optional[RegionEnum] = None, player_id: int = None, offset: int = 0
) -> GenshinClient: ) -> ZZZClient:
player = await self.players_service.get_player(user_id, region, player_id, offset) player = await self.players_service.get_player(user_id, region, player_id, offset)
if player is None: if player is None:
raise PlayerNotFoundError(user_id) raise PlayerNotFoundError(user_id)
@ -334,7 +334,7 @@ class GenshinHelper(Plugin):
device_id = devices.device_id device_id = devices.device_id
device_fp = devices.device_fp device_fp = devices.device_fp
return GenshinClient( return ZZZClient(
cookies, cookies,
region=region, region=region,
account_id=player.account_id, account_id=player.account_id,
@ -347,7 +347,7 @@ class GenshinHelper(Plugin):
@asynccontextmanager @asynccontextmanager
async def public_genshin( async def public_genshin(
self, user_id: int, region: Optional[RegionEnum] = None, uid: Optional[int] = None self, user_id: int, region: Optional[RegionEnum] = None, uid: Optional[int] = None
) -> GenshinClient: ) -> ZZZClient:
if not (region or uid): if not (region or uid):
player = await self.players_service.get_player(user_id, region) player = await self.players_service.get_player(user_id, region)
if player: if player:
@ -370,7 +370,7 @@ class GenshinHelper(Plugin):
device_id = devices.device_id device_id = devices.device_id
device_fp = devices.device_fp device_fp = devices.device_fp
async with GenshinClient( async with ZZZClient(
cookies.data, cookies.data,
region=region, region=region,
player_id=uid, player_id=uid,
@ -392,7 +392,7 @@ class GenshinHelper(Plugin):
region: Optional[RegionEnum] = None, region: Optional[RegionEnum] = None,
uid: Optional[int] = None, uid: Optional[int] = None,
offset: int = 0, offset: int = 0,
) -> GenshinClient: ) -> ZZZClient:
try: try:
async with self.genshin(user_id, region, uid, offset) as client: async with self.genshin(user_id, region, uid, offset) as client:
client.public = False client.public = False

View File

@ -2,7 +2,6 @@ from typing import Optional
from enkanetwork import Assets from enkanetwork import Assets
from core.dependence.assets import AssetsService
from core.plugin import Plugin from core.plugin import Plugin
from core.services.players.services import PlayerInfoService, PlayersService from core.services.players.services import PlayerInfoService, PlayersService
from metadata.genshin import AVATAR_DATA from metadata.genshin import AVATAR_DATA
@ -13,10 +12,8 @@ class PlayerInfoSystem(Plugin):
def __init__( def __init__(
self, self,
player_service: PlayersService = None, player_service: PlayersService = None,
assets_service: AssetsService = None,
player_info_service: PlayerInfoService = None, player_info_service: PlayerInfoService = None,
) -> None: ) -> None:
self.assets_service = assets_service
self.player_info_service = player_info_service self.player_info_service = player_info_service
self.player_service = player_service self.player_service = player_service

View File

@ -27,7 +27,7 @@ from plugins.tools.recognize import RecognizeSystem
from utils.log import logger from utils.log import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from simnet import GenshinClient from simnet import ZZZClient
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
@ -106,7 +106,7 @@ class SignSystem(Plugin):
async def start_sign( async def start_sign(
self, self,
client: "GenshinClient", client: "ZZZClient",
challenge: Optional[str] = None, challenge: Optional[str] = None,
validate: Optional[str] = None, validate: Optional[str] = None,
is_sleep: bool = False, is_sleep: bool = False,

View File

@ -16,7 +16,7 @@ from utils.log import logger
from utils.uid import mask_number from utils.uid import mask_number
if TYPE_CHECKING: if TYPE_CHECKING:
from simnet import GenshinClient from simnet import ZZZClient
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
@ -34,62 +34,30 @@ class DailyNotePlugin(Plugin):
self.template_service = template self.template_service = template
self.helper = helper self.helper = helper
async def _get_daily_note(self, client: "GenshinClient") -> RenderResult: async def _get_daily_note(self, client: "ZZZClient") -> RenderResult:
daily_info = await client.get_genshin_notes(client.player_id) daily_info = await client.get_zzz_notes(client.player_id)
day = datetime.now().strftime("%m-%d %H:%M") + " 星期" + "一二三四五六日"[datetime.now().weekday()] day = datetime.now().strftime("%m-%d %H:%M") + " 星期" + "一二三四五六日"[datetime.now().weekday()]
resin_recovery_time = ( resin_recovery_time = (
daily_info.resin_recovery_time.strftime("%m-%d %H:%M") daily_info.stamina_recover_time.strftime("%m-%d %H:%M")
if daily_info.max_resin - daily_info.current_resin if daily_info.max_stamina - daily_info.current_stamina
else None else None
) )
realm_recovery_time = (
(datetime.now().astimezone() + daily_info.remaining_realm_currency_recovery_time).strftime("%m-%d %H:%M")
if daily_info.max_realm_currency - daily_info.current_realm_currency
else None
)
remained_time = None
for i in daily_info.expeditions:
if remained_time:
if remained_time < i.remaining_time:
remained_time = i.remaining_time
else:
remained_time = i.remaining_time
if remained_time:
remained_time = (datetime.now().astimezone() + remained_time).strftime("%m-%d %H:%M")
transformer, transformer_ready, transformer_recovery_time = False, None, None
if daily_info.remaining_transformer_recovery_time is not None:
transformer = True
transformer_ready = daily_info.remaining_transformer_recovery_time.total_seconds() == 0
transformer_recovery_time = daily_info.transformer_recovery_time.strftime("%m-%d %H:%M")
render_data = { render_data = {
"uid": mask_number(client.player_id), "uid": mask_number(client.player_id),
"day": day, "day": day,
"resin_recovery_time": resin_recovery_time, "resin_recovery_time": resin_recovery_time,
"current_resin": daily_info.current_resin, "current_resin": daily_info.current_stamina,
"max_resin": daily_info.max_resin, "max_resin": daily_info.max_stamina,
"realm_recovery_time": realm_recovery_time, "exp_status": daily_info.vhs_sale.sale_state.name,
"current_realm_currency": daily_info.current_realm_currency, "current_train_score": daily_info.current_train_score,
"max_realm_currency": daily_info.max_realm_currency, "max_train_score": daily_info.max_train_score,
"claimed_commission_reward": daily_info.claimed_commission_reward,
"completed_commissions": daily_info.completed_commissions,
"max_commissions": daily_info.max_commissions,
"expeditions": bool(daily_info.expeditions),
"remained_time": remained_time,
"current_expeditions": len(daily_info.expeditions),
"max_expeditions": daily_info.max_expeditions,
"remaining_resin_discounts": daily_info.remaining_resin_discounts,
"max_resin_discounts": daily_info.max_resin_discounts,
"transformer": transformer,
"transformer_ready": transformer_ready,
"transformer_recovery_time": transformer_recovery_time,
} }
render_result = await self.template_service.render( render_result = await self.template_service.render(
"genshin/daily_note/daily_note.jinja2", "zzz/daily_note/daily_note.jinja2",
render_data, render_data,
{"width": 600, "height": 548}, {"width": 600, "height": 300},
full_page=False, full_page=False,
ttl=8 * 60, ttl=8 * 60,
) )

View File

@ -17,7 +17,7 @@ from plugins.tools.genshin import GenshinHelper, CookiesNotFoundError, PlayerNot
from utils.log import logger from utils.log import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from simnet import GenshinClient from simnet import ZZZClient
__all__ = ("DailyNoteTasksPlugin",) __all__ = ("DailyNoteTasksPlugin",)
@ -54,7 +54,7 @@ class DailyNoteTasksPlugin(Plugin.Conversation):
await message.reply_text(text, reply_markup=ReplyKeyboardRemove()) await message.reply_text(text, reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END return ConversationHandler.END
note_user = await self.note_system.get_single_task_user(user.id) note_user = await self.note_system.get_single_task_user(user.id)
url = f"{config.pass_challenge_user_web}/tasks1?command=tasks&bot_data={note_user.web_config}" url = f"{config.pass_challenge_user_web}/tasks4?command=tasks&bot_data={note_user.web_config}"
text = ( text = (
f'你好 {user.mention_markdown_v2()} {escape_markdown("!请点击下方按钮,开始设置,或者回复退出取消操作")}' f'你好 {user.mention_markdown_v2()} {escape_markdown("!请点击下方按钮,开始设置,或者回复退出取消操作")}'
) )
@ -72,12 +72,9 @@ class DailyNoteTasksPlugin(Plugin.Conversation):
async def check_genshin_user(self, user_id: int, request_note: bool) -> str: async def check_genshin_user(self, user_id: int, request_note: bool) -> str:
try: try:
async with self.helper.genshin(user_id) as client: async with self.helper.genshin(user_id) as client:
client: "GenshinClient" client: "ZZZClient"
if request_note: if request_note:
if client.region == Region.CHINESE: await client.get_zzz_notes()
await client.get_genshin_notes_by_stoken()
else:
await client.get_genshin_notes()
return "ok" return "ok"
except ValueError: except ValueError:
return "Cookies 缺少 stoken ,请尝试重新绑定账号。" return "Cookies 缺少 stoken ,请尝试重新绑定账号。"
@ -112,7 +109,7 @@ class DailyNoteTasksPlugin(Plugin.Conversation):
validate = WebAppData(**result.data) validate = WebAppData(**result.data)
except ValidationError: except ValidationError:
await message.reply_text( await message.reply_text(
"数据错误\n树脂提醒数值必须在 60 ~ 200 之间\n洞天宝钱提醒数值必须在 100 ~ 2400 之间\n每日任务提醒时间必须在 0 ~ 23 之间", "数据错误\n电量提醒数值必须在 60 ~ 200 之间\n每日任务提醒时间必须在 0 ~ 23 之间",
reply_markup=ReplyKeyboardRemove(), reply_markup=ReplyKeyboardRemove(),
) )
return ConversationHandler.END return ConversationHandler.END

View File

@ -15,7 +15,7 @@ from gram_core.basemodel import RegionEnum
from gram_core.services.cookies import CookiesService from gram_core.services.cookies import CookiesService
from gram_core.services.cookies.models import CookiesStatusEnum from gram_core.services.cookies.models import CookiesStatusEnum
from gram_core.services.users.services import UserAdminService from gram_core.services.users.services import UserAdminService
from plugins.genshin.redeem.runner import RedeemRunner, RedeemResult, RedeemQueueFull from plugins.zzz.redeem.runner import RedeemRunner, RedeemResult, RedeemQueueFull
from plugins.tools.genshin import GenshinHelper from plugins.tools.genshin import GenshinHelper
from utils.log import logger from utils.log import logger

View File

@ -10,7 +10,7 @@ from gram_core.basemodel import RegionEnum
from plugins.tools.genshin import GenshinHelper from plugins.tools.genshin import GenshinHelper
if TYPE_CHECKING: if TYPE_CHECKING:
from simnet import GenshinClient from simnet import ZZZClient
@dataclass @dataclass
@ -19,7 +19,7 @@ class RedeemResult:
code: str code: str
message: Optional[Message] = None message: Optional[Message] = None
error: Optional[str] = None error: Optional[str] = None
uid: Optional[int] = 0 uid: Optional[int] = None
count: Optional[List[int]] = None count: Optional[List[int]] = None
@ -82,7 +82,7 @@ class RedeemRunner:
region=RegionEnum.HOYOLAB if only_region else None, region=RegionEnum.HOYOLAB if only_region else None,
player_id=result.uid, player_id=result.uid,
) as client: ) as client:
client: "GenshinClient" client: "ZZZClient"
result.uid = client.player_id result.uid = client.player_id
await client.redeem_code_by_hoyolab(result.code) await client.redeem_code_by_hoyolab(result.code)
except RegionNotSupported: except RegionNotSupported:

View File

@ -16,8 +16,7 @@ from utils.uid import mask_number
if TYPE_CHECKING: if TYPE_CHECKING:
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from simnet.models.genshin.chronicle.stats import GenshinUserStats from simnet import ZZZClient
from simnet import GenshinClient
__all__ = ("PlayerStatsPlugins",) __all__ = ("PlayerStatsPlugins",)
@ -33,6 +32,7 @@ class PlayerStatsPlugins(Plugin):
@handler.message(filters.Regex("^玩家统计查询(.*)"), player=True, block=False) @handler.message(filters.Regex("^玩家统计查询(.*)"), player=True, block=False)
async def command_start(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> Optional[int]: async def command_start(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> Optional[int]:
user_id = await self.get_real_user_id(update) user_id = await self.get_real_user_id(update)
nickname = self.get_real_user_name(update)
uid, offset = self.get_real_uid_or_offset(update) uid, offset = self.get_real_uid_or_offset(update)
message = update.effective_message message = update.effective_message
self.log_user(update, logger.info, "查询游戏用户命令请求") self.log_user(update, logger.info, "查询游戏用户命令请求")
@ -40,7 +40,7 @@ class PlayerStatsPlugins(Plugin):
async with self.helper.genshin_or_public(user_id, uid=uid, offset=offset) as client: async with self.helper.genshin_or_public(user_id, uid=uid, offset=offset) as client:
if not client.public: if not client.public:
await client.get_record_cards() await client.get_record_cards()
render_result = await self.render(client, client.player_id) render_result = await self.render(client, nickname)
except TooManyRequestPublicCookies: except TooManyRequestPublicCookies:
await message.reply_text("用户查询次数过多 请稍后重试") await message.reply_text("用户查询次数过多 请稍后重试")
return return
@ -56,65 +56,38 @@ class PlayerStatsPlugins(Plugin):
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
await render_result.reply_photo(message, filename=f"{client.player_id}.png") await render_result.reply_photo(message, filename=f"{client.player_id}.png")
async def render(self, client: "GenshinClient", uid: Optional[int] = None) -> RenderResult: async def render(self, client: "ZZZClient", nickname: str) -> RenderResult:
if uid is None: uid = client.player_id
uid = client.player_id user_info = await client.get_zzz_user(uid)
user_info = await client.get_genshin_user(uid)
# 因为需要替换线上图片地址为本地地址,先克隆数据,避免修改原数据 # 因为需要替换线上图片地址为本地地址,先克隆数据,避免修改原数据
user_info = user_info.copy(deep=True) user_info = user_info.copy(deep=True)
data = { data = {
"uid": mask_number(uid), "uid": mask_number(uid),
"info": user_info.info,
"stats": user_info.stats, "stats": user_info.stats,
"explorations": user_info.explorations, "nickname": nickname,
"skip_explor": [10],
"teapot": user_info.teapot,
"stats_labels": [ "stats_labels": [
("活跃天数", "days_active"), ("活跃天数", "active_days"),
("成就达成数", "achievements"), ("获取角色数", "avatar_num"),
("获取角色数", "characters"), ("绳网声望", "world_level_name"),
("深境螺旋", "spiral_abyss"), ("式舆防卫战防线", "cur_period_zone_layer_count"),
("解锁传送点", "unlocked_waypoints"), ("获得邦布数", "buddy_num"),
("解锁秘境", "unlocked_domains"),
("奇馈宝箱数", "remarkable_chests"),
("华丽宝箱数", "luxurious_chests"),
("珍贵宝箱数", "precious_chests"),
("精致宝箱数", "exquisite_chests"),
("普通宝箱数", "common_chests"),
("风神瞳", "anemoculi"),
("岩神瞳", "geoculi"),
("雷神瞳", "electroculi"),
("草神瞳", "dendroculi"),
("水神瞳", "hydroculi"),
], ],
"style": random.choice(["mondstadt", "liyue"]), # nosec "style": random.choice(["mondstadt", "liyue"]), # nosec
} }
await self.cache_images(user_info)
return await self.template_service.render( return await self.template_service.render(
"genshin/stats/stats.jinja2", "zzz/stats/stats.jinja2",
data, data,
{"width": 650, "height": 800}, {"width": 650, "height": 400},
full_page=True, full_page=True,
) )
async def cache_images(self, data: "GenshinUserStats") -> None:
"""缓存所有图片到本地"""
# TODO: 并发下载所有资源
# 探索地区
for item in data.explorations:
item.__config__.allow_mutation = True
item.icon = await self.download_resource(item.icon)
item.cover = await self.download_resource(item.cover)
async def stats_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"): async def stats_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"):
callback_query = update.callback_query callback_query = update.callback_query
user = update.effective_user user = update.effective_user
nickname = self.get_real_user_name(update)
user_id = user.id user_id = user.id
uid = IInlineUseData.get_uid_from_context(context) uid = IInlineUseData.get_uid_from_context(context)
@ -124,7 +97,7 @@ class PlayerStatsPlugins(Plugin):
async with self.helper.genshin_or_public(user_id, uid=uid) as client: async with self.helper.genshin_or_public(user_id, uid=uid) as client:
if not client.public: if not client.public:
await client.get_record_cards() await client.get_record_cards()
render_result = await self.render(client, client.player_id) render_result = await self.render(client, nickname)
except TooManyRequestPublicCookies: except TooManyRequestPublicCookies:
notice = "用户查询次数过多 请稍后重试" notice = "用户查询次数过多 请稍后重试"
except AttributeError as exc: except AttributeError as exc:

View File

@ -43,7 +43,6 @@ dependencies = [
"playwright==1.44.0", "playwright==1.44.0",
"aiosqlite[sqlite]<1.0.0,>=0.20.0", "aiosqlite[sqlite]<1.0.0,>=0.20.0",
"simnet @ git+https://github.com/PaiGramTeam/SIMNet", "simnet @ git+https://github.com/PaiGramTeam/SIMNet",
"gcsim-pypi<3.0.0,>=2.23.0",
"psutil<7.0.0,>=6.0.0", "psutil<7.0.0,>=6.0.0",
"influxdb-client[async,ciso]>=1.43.0", "influxdb-client[async,ciso]>=1.43.0",
] ]

View File

@ -7,10 +7,10 @@ aiohttp==3.9.5
aiolimiter==1.1.0 aiolimiter==1.1.0
aiosignal==1.3.1 aiosignal==1.3.1
aiosqlite[sqlite]==0.20.0 aiosqlite[sqlite]==0.20.0
alembic==1.13.1 alembic==1.13.2
anyio==4.4.0 anyio==4.4.0
apscheduler==3.10.4 apscheduler==3.10.4
arko-wrapper==0.2.8 arko-wrapper==0.3.0
async-lru==2.0.4 async-lru==2.0.4
async-timeout==4.0.3; python_full_version < "3.11.3" async-timeout==4.0.3; python_full_version < "3.11.3"
asyncmy==0.2.9 asyncmy==0.2.9
@ -19,7 +19,7 @@ backports-zoneinfo==0.2.1; python_version < "3.9"
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
black==24.4.2 black==24.4.2
cachetools==5.3.3 cachetools==5.3.3
certifi==2024.6.2 certifi==2024.7.4
cffi==1.16.0; platform_python_implementation != "PyPy" cffi==1.16.0; platform_python_implementation != "PyPy"
ciso8601==2.3.1 ciso8601==2.3.1
click==8.1.7 click==8.1.7
@ -31,12 +31,11 @@ email-validator==2.2.0
enkanetwork-py @ git+https://github.com/PaiGramTeam/EnkaNetwork.py@ca2d8e5fa14714755c6990e986fc30fe430b0e4a enkanetwork-py @ git+https://github.com/PaiGramTeam/EnkaNetwork.py@ca2d8e5fa14714755c6990e986fc30fe430b0e4a
et-xmlfile==1.1.0 et-xmlfile==1.1.0
exceptiongroup==1.2.1; python_version < "3.11" exceptiongroup==1.2.1; python_version < "3.11"
fakeredis==2.23.2 fakeredis==2.23.3
fastapi==0.111.0 fastapi==0.111.0
fastapi-cli==0.0.4 fastapi-cli==0.0.4
flaky==3.8.1 flaky==3.8.1
frozenlist==1.4.1 frozenlist==1.4.1
gcsim-pypi==2.23.0
gitdb==4.0.11 gitdb==4.0.11
gitpython==3.1.43 gitpython==3.1.43
greenlet==3.0.3 greenlet==3.0.3
@ -57,11 +56,11 @@ markupsafe==2.1.5
mdurl==0.1.2 mdurl==0.1.2
multidict==6.0.5 multidict==6.0.5
mypy-extensions==1.0.0 mypy-extensions==1.0.0
openpyxl==3.1.4 openpyxl==3.1.5
orjson==3.10.5 orjson==3.10.6
packaging==24.1 packaging==24.1
pathspec==0.12.1 pathspec==0.12.1
pillow==10.3.0 pillow==10.4.0
platformdirs==4.2.2 platformdirs==4.2.2
playwright==1.44.0 playwright==1.44.0
pluggy==1.5.0 pluggy==1.5.0
@ -82,14 +81,14 @@ python-multipart==0.0.9
python-telegram-bot[ext,rate-limiter]==21.3 python-telegram-bot[ext,rate-limiter]==21.3
pytz==2024.1 pytz==2024.1
pyyaml==6.0.1 pyyaml==6.0.1
rapidfuzz==3.9.3 rapidfuzz==3.9.4
reactivex==4.0.4 reactivex==4.0.4
redis==5.0.7 redis==5.0.7
rich==13.7.1 rich==13.7.1
sentry-sdk==2.7.0 sentry-sdk==2.7.1
setuptools==70.1.1 setuptools==70.2.0
shellingham==1.5.4 shellingham==1.5.4
simnet @ git+https://github.com/PaiGramTeam/SIMNet@277a33321a20909541b46bf4ecf794fd47e19fb1 simnet @ git+https://github.com/PaiGramTeam/SIMNet@05fcb568d6c1fe44a4f917c996198bfe62a00053
six==1.16.0 six==1.16.0
smmap==5.0.1 smmap==5.0.1
sniffio==1.3.1 sniffio==1.3.1

View File

@ -11,7 +11,7 @@
<div class="container mx-auto px-5 py-10 max-w-7xl"> <div class="container mx-auto px-5 py-10 max-w-7xl">
<div class="header p-6 flex mb-8 rounded-xl bg-cover justify-between"> <div class="header p-6 flex mb-8 rounded-xl bg-cover justify-between">
<div> <div>
<h1 class="text-4xl italic">PaiGram</h1> <h1 class="text-4xl italic">MibooGram</h1>
<h1 class="text-2xl">使用说明</h1> <h1 class="text-2xl">使用说明</h1>
</div> </div>
<div> <div>
@ -33,18 +33,6 @@
</div> </div>
<div class="grid grid-cols-4 py-4 px-2"> <div class="grid grid-cols-4 py-4 px-2">
<!-- WIKI类 --> <!-- WIKI类 -->
<div class="command">
<div class="command-name">/weapon</div>
<div class="command-description">查询武器</div>
</div>
<div class="command rounded-xl flex-1">
<div class="command-name">/strategy</div>
<div class="command-description">查询角色攻略</div>
</div>
<div class="command">
<div class="command-name">/material</div>
<div class="command-description">角色培养素材查询</div>
</div>
<!-- UID 查询类 --> <!-- UID 查询类 -->
<div class="command"> <div class="command">
<div class="command-name"> <div class="command-name">
@ -53,13 +41,6 @@
</div> </div>
<div class="command-description">玩家统计查询</div> <div class="command-description">玩家统计查询</div>
</div> </div>
<div class="command">
<div class="command-name">
/player_card
<i class="fa fa-user-circle-o ml-2"></i>
</div>
<div class="command-description">角色卡片</div>
</div>
<!-- 最高查询类 --> <!-- 最高查询类 -->
<div class="command"> <div class="command">
<div class="command-name"> <div class="command-name">
@ -68,63 +49,6 @@
</div> </div>
<div class="command-description">查询实时便笺</div> <div class="command-description">查询实时便笺</div>
</div> </div>
<div class="command">
<div class="command-name">
/ledger
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">查询当月旅行札记</div>
</div>
<div class="command">
<div class="command-name">
/abyss
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">查询当期深渊战绩</div>
</div>
<div class="command">
<div class="command-name">
/abyss_team
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">查询深渊推荐配队</div>
</div>
<div class="command">
<div class="command-name">
/avatars
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">查询角色练度</div>
</div>
<div class="command">
<div class="command-name">
/reg_time
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">原神账号注册时间</div>
</div>
<!--! gacha_log 相关 -->
<div class="command">
<div class="command-name">
/gacha_log
<i class="fa fa-user-circle-o ml-2"></i>
</div>
<div class="command-description">抽卡记录</div>
</div>
<div class="command">
<div class="command-name">
/gacha_count
<i class="fa fa-user-circle-o ml-2"></i>
</div>
<div class="command-description">抽卡统计</div>
</div>
<div class="command">
<div class="command-name">
/pay_log
<i class="fa fa-user-circle-o ml-2"></i>
</div>
<div class="command-description">充值记录</div>
</div>
<div class="command"> <div class="command">
<div class="command-name"> <div class="command-name">
/sign /sign
@ -132,30 +56,6 @@
</div> </div>
<div class="command-description">每日签到 | 查询</div> <div class="command-description">每日签到 | 查询</div>
</div> </div>
<div class="command">
<div class="command-name">/daily_material</div>
<div class="command-description">每日素材</div>
</div>
<!--! 其他 -->
<div class="command">
<div class="command-name">/hilichurls</div>
<div class="command-description">丘丘语字典</div>
</div>
<div class="command">
<div class="command-name">/birthday</div>
<div class="command-description">角色生日</div>
</div>
<div class="command">
<div class="command-name">
/birthday_card
<i class="fa fa-id-card-o ml-2"></i>
</div>
<div class="command-description">领取角色生日画片</div>
</div>
<div class="command">
<div class="command-name">/calendar</div>
<div class="command-description">活动日历</div>
</div>
</div> </div>
</div> </div>
@ -167,46 +67,6 @@
<h1>其他命令</h1> <h1>其他命令</h1>
</div> </div>
<div class="grid grid-cols-4 py-4 px-2"> <div class="grid grid-cols-4 py-4 px-2">
<div class="command">
<div class="command-name">/wish</div>
<div class="command-description">抽卡模拟器(非洲人模拟器)</div>
</div>
<div class="command">
<div class="command-name">/set_wish</div>
<div class="command-description">抽卡模拟器定轨</div>
</div>
<div class="command">
<div class="command-name">/quiz</div>
<div class="command-description">
派蒙的十万个为什么
</div>
</div>
<!--! gacha_log 相关 -->
<div class="command">
<div class="command-name">/gacha_log_import</div>
<div class="command-description">导入抽卡记录</div>
</div>
<div class="command">
<div class="command-name">/gacha_log_export</div>
<div class="command-description">导出抽卡记录</div>
</div>
<div class="command">
<div class="command-name">/gacha_log_delete</div>
<div class="command-description">删除抽卡记录</div>
</div>
<!--! pay_log 相关 -->
<div class="command">
<div class="command-name">/pay_log_import</div>
<div class="command-description">导入充值记录</div>
</div>
<div class="command">
<div class="command-name">/pay_log_export</div>
<div class="command-description">导出充值记录</div>
</div>
<div class="command">
<div class="command-name">/pay_log_delete</div>
<div class="command-description">删除充值记录</div>
</div>
<!--! user 相关 --> <!--! user 相关 -->
<div class="command"> <div class="command">
<div class="command-name">/setuid</div> <div class="command-name">/setuid</div>

View File

@ -0,0 +1,121 @@
@font-face {
font-family: "tttgbnumber";
src: url("../../fonts/tttgbnumber.ttf");
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
font-size: 16px;
font-family: "tttgbnumber", system-ui;
transform: scale(1.5);
transform-origin: 0 0;
color: #1e1f20;
}
.container {
width: 400px;
height: 365px;
background: #f0eae3;
padding: 8px 16px;
}
.title {
display: flex;
position: relative;
margin-bottom: 9px;
color: #504c49;
}
.title .id {
flex: 1;
line-height: 18px;
padding-left: 10px;
}
.title .id:before {
content: " ";
position: absolute;
width: 5px;
height: 24px;
border-radius: 1px;
left: 0px;
top: -3px;
background: #d3bc8d;
}
.title .day {
line-height: 18px;
}
.item {
border: 1px solid #dfd8d1;
display: flex;
height: 49px;
margin-top: 5px;
}
.item .main {
display: flex;
flex: 1;
background-color: #f5f1eb;
position: relative;
/* font-weight: bold; */
}
.item .main .bg {
width: 100px;
height: 100%;
position: absolute;
left: 0;
background-size: 100% auto;
background-image: url(./items/bg.png);
}
.item .main .icon {
width: 25px;
height: 25px;
margin: 11px 8px 0 8px;
}
.item .main .info {
padding-top: 7px;
}
.item .main .info .name {
font-size: 14px;
/* color: #5f5f5d; */
line-height: 1;
margin-bottom: 7px;
}
.item .main .info .time {
font-size: 12px;
/* font-weight: 400; */
color: #5f5f5d;
line-height: 1;
}
.item .right {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 100%;
background-color: #ece3d8;
font-size: 16px;
color: #504c49;
line-height: 55px;
}
.item .right .red {
color: #f24e4c;
}

View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8"/>
<link rel="shortcut icon" href="#"/>
<link rel="stylesheet" type="text/css" href="./daily_note.css"/>
</head>
<body>
<div class="container" id="container">
<div class="title">
<div class="id">
<span>ID{{ uid }}</span>
</div>
<div class="day">
<span>{{ day }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/电量.png" alt=""/>
<div class="info">
<div class="name">电量</div>
<div class="time">
{% if resin_recovery_time %}
将于{{ resin_recovery_time }} 全部恢复
{% else %}
电量已完全恢复
{% endif %}
</div>
</div>
</div>
<div class="right">
<span class="{% if current_resin/(max_resin or 1) > 0.9 %}red{% endif %}">
{{ current_resin }}/{{ max_resin }}
</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/Investigation-Point.png" alt=""/>
<div class="info">
<div class="name">录像店经营</div>
</div>
</div>
<div class="right">
<span class="{% if exp_status == 'DONE' %}red{% endif %}">
{% if exp_status == 'DONE' %}
已完成
{% else %}
正在营业
{% endif %}
</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="./items/Inter-Knot-Credit.png" alt="" />
<div class="info">
<div class="name">每日活跃</div>
<div class="time">
{% if current_train_score == max_train_score %}
每日活跃已完成
{% else %}
每日活跃未完成
{% endif %}
</div>
</div>
</div>
<div class="right">
<span
class="{% if current_train_score != max_train_score %}red{% endif %}"
>{{ current_train_score }}/{{ max_train_score }}</span>
</div>
</div>
</div>
</body>
<script type="text/javascript"></script>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

View File

@ -0,0 +1,62 @@
:root {
--primary: #ffeabd;
}
body {
background-color: #f5f6fb;
}
.header {
background-image: url(../background/liyue.png);
box-shadow: 0 0 16px rgb(255 233 144 / 50%);
}
.box {
background-color: #9c433d;
box-shadow: 0 0 16px rgb(255 233 144 / 50%);
}
.box-title {
background-color: rgb(255, 200, 122, 0.1);
--tw-ring-color: #ff9966;
}
.pointer-bar {
width: 95%;
height: 8px;
display: inline-block;
background-color: rgb(0, 0, 0, 0.1);
border-radius: 0.25rem;
}
.pointer-progress-bar {
border-radius: 0.25rem;
height: 100%;
background: linear-gradient(to bottom, #f5efcd, #f8eabd, #ffdf90);
}
.name {
background: linear-gradient(to bottom, #ffffff, #ffeabd, #ffdf90);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.uid {
color: var(--primary);
background: linear-gradient(to right, rgb(0, 0, 0, 0), #cc6666, rgb(0, 0, 0, 0));
}
.about {
background-color: #e0dad3;
color: #8a4d30;
}
.box-stats {
color: var(--primary);
}
.box-stats-label {
color: var(--primary) !important;
opacity: 0.65;
}

View File

@ -0,0 +1,256 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="./liyue.css" rel="stylesheet" />
<link type="text/css" href="../../../styles/public.css" rel="stylesheet" />
<script src="../../../js/tailwindcss-3.1.8.js"></script>
</head>
<body class="text-neutral-600">
<div class="mx-auto max-w-[600px] py-8">
<div class="header p-6 flex mb-8 rounded-xl bg-cover justify-between">
<div class="flex flex-col items-center justify-center">
<h1 class="text-4xl italic name mb-2 px-2">
小何
<span class="text-lg">lv.58</span>
</h1>
<h1 class="italic uid px-10">UID - 125324176</h1>
</div>
</div>
<div class="box pt-4 rounded-xl overflow-hidden">
<div>
<h2 class="box-title text-center text-xl ring text-neutral-100 p-1">
数据总览
</h2>
<div class="p-6 grid grid-cols-4 gap-4 text-center">
<div class="">
<div class="text-xl box-stats">493</div>
<div class="text-neutral-400 box-stats-label">活跃天数</div>
</div>
<div class="">
<div class="text-xl box-stats">536</div>
<div class="text-neutral-400 box-stats-label">成就达成数</div>
</div>
<div class="">
<div class="text-xl box-stats">38</div>
<div class="text-neutral-400 box-stats-label">获取角色数</div>
</div>
<div class="">
<div class="text-xl box-stats">12-3</div>
<div class="text-neutral-400 box-stats-label">深境螺旋</div>
</div>
<div class="">
<div class="text-xl box-stats">227</div>
<div class="text-neutral-400 box-stats-label">解锁传送点</div>
</div>
<div class="">
<div class="text-xl box-stats">41</div>
<div class="text-neutral-400 box-stats-label">解锁秘境</div>
</div>
<div class="">
<div class="text-xl box-stats">58</div>
<div class="text-neutral-400 box-stats-label">奇馈宝箱数</div>
</div>
<div class="">
<div class="text-xl box-stats">128</div>
<div class="text-neutral-400 box-stats-label">华丽宝箱数</div>
</div>
<div class="">
<div class="text-xl box-stats">316</div>
<div class="text-neutral-400 box-stats-label">珍贵宝箱数</div>
</div>
<div class="">
<div class="text-xl box-stats">1184</div>
<div class="text-neutral-400 box-stats-label">精致宝箱数</div>
</div>
<div class="">
<div class="text-xl box-stats">1594</div>
<div class="text-neutral-400 box-stats-label">普通宝箱数</div>
</div>
<div class="">
<div class="text-xl box-stats">65</div>
<div class="text-neutral-400 box-stats-label">风神瞳</div>
</div>
<div class="">
<div class="text-xl box-stats">131</div>
<div class="text-neutral-400 box-stats-label">岩神瞳</div>
</div>
<div class="">
<div class="text-xl box-stats">180</div>
<div class="text-neutral-400 box-stats-label">雷神瞳</div>
</div>
<div class="">
<div class="text-xl box-stats">80</div>
<div class="text-neutral-400 box-stats-label">草神瞳</div>
</div>
</div>
</div>
<div>
<h2 class="box-title text-center text-xl ring text-neutral-100 p-1">
世界探索
</h2>
<div class="p-6 grid grid-cols-4 gap-4 text-center text-neutral-100">
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Xumi.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Xumi.png"
/>
<div class="text-sm w-full truncate">须弥</div>
<div class="text-xs">28.0%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 28%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_ChasmsMaw.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_ChasmsMaw.png"
/>
<div class="text-sm w-full truncate">层岩巨渊·地下矿区</div>
<div class="text-xs">98.7%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 98.7%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_ChasmsMaw.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_ChasmsMaw.png"
/>
<div class="text-sm w-full truncate">层岩巨渊</div>
<div class="text-xs">92.9%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 92.9%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Enkanomiya.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Enkanomiya.png"
/>
<div class="text-sm w-full truncate">渊下宫</div>
<div class="text-xs">98.3%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 98.3%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Daoqi.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Daoqi.png"
/>
<div class="text-sm w-full truncate">稻妻</div>
<div class="text-xs">100.0%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 100%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Dragonspine.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Dragonspine.png"
/>
<div class="text-sm w-full truncate">龙脊雪山</div>
<div class="text-xs">83.6%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 83.6%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Liyue.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Liyue.png"
/>
<div class="text-sm w-full truncate">璃月</div>
<div class="text-xs">95.9%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 95.9%"></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="
background-image: url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Mengde.png');
"
>
<img
class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Mengde.png"
/>
<div class="text-sm w-full truncate">蒙德</div>
<div class="text-xs">100.0%</div>
<div class="pointer-bar">
<div class="pointer-progress-bar" style="width: 100%"></div>
</div>
</div>
</div>
</div>
<div class="about text-center leading-8 text-xs opacity-50">
所有数据会有一小时延迟 以游戏内为准 此处仅供参考
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,49 @@
body {
background-color: #f5f6fb;
}
.header {
background-image: url(../background/mondstadt.png);
box-shadow: 0 0 8px rgb(123 242 248 / 50%);
}
.box {
background-color: #fdfdf3;
box-shadow: 0 0 8px rgb(123 242 248 / 50%);
}
.box-title {
background-color: #43849abb;
--tw-ring-color: #43849a;
}
.pointer-bar {
width: 95%;
height: 8px;
display: inline-block;
background-color: rgb(0, 0, 0, 0.2);
border-radius: 0.25rem;
}
.pointer-progress-bar {
border-radius: 0.25rem;
height: 100%;
background: #fff6e2;
}
.name {
background: linear-gradient(to bottom, #66bbee, #5ddddd, #55dddd);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.uid {
color: #fff;
background: linear-gradient(to right, rgb(0, 0, 0, 0), #5ddddd, rgb(0, 0, 0, 0));
}
.about {
background-color: #e0dad3;
color: #8a4d30;
}

View File

@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="./mondstadt.css" rel="stylesheet" />
<link type="text/css" href="../../../styles/public.css" rel="stylesheet" />
<script src="../../../js/tailwindcss-3.1.8.js"></script>
</head>
<body class="text-neutral-600">
<div class="mx-auto max-w-[600px] py-8">
<div class="header p-6 flex mb-8 rounded-xl bg-cover justify-between">
<div class="flex flex-col items-center justify-center">
<h1 class="text-4xl italic name mb-2 px-2">
小何
<span class="text-lg">lv.58</span>
</h1>
<h1 class="italic uid px-10">UID - 125324176</h1>
</div>
</div>
<div class="box pt-4 rounded-xl overflow-hidden">
<div>
<h2 class="box-title text-center text-xl ring text-neutral-100 p-1">
数据总览
</h2>
<div class="p-6 grid grid-cols-4 gap-4 text-center">
<div class="">
<div class="text-xl">491</div>
<div class="text-neutral-400">活跃天数</div>
</div>
<div class="">
<div class="text-xl">536</div>
<div class="text-neutral-400">成就达成数</div>
</div>
<div class="">
<div class="text-xl">38</div>
<div class="text-neutral-400">获取角色数</div>
</div>
<div class="">
<div class="text-xl">12-3</div>
<div class="text-neutral-400">深境螺旋</div>
</div>
<div class="">
<div class="text-xl">227</div>
<div class="text-neutral-400">解锁传送点</div>
</div>
<div class="">
<div class="text-xl">41</div>
<div class="text-neutral-400">解锁秘境</div>
</div>
<div class="">
<div class="text-xl">58</div>
<div class="text-neutral-400">奇馈宝箱数</div>
</div>
<div class="">
<div class="text-xl">127</div>
<div class="text-neutral-400">华丽宝箱数</div>
</div>
<div class="">
<div class="text-xl">316</div>
<div class="text-neutral-400">珍贵宝箱数</div>
</div>
<div class="">
<div class="text-xl">1180</div>
<div class="text-neutral-400">精致宝箱数</div>
</div>
<div class="">
<div class="text-xl">1591</div>
<div class="text-neutral-400">普通宝箱数</div>
</div>
<div class="">
<div class="text-xl">65</div>
<div class="text-neutral-400">风神瞳</div>
</div>
<div class="">
<div class="text-xl">131</div>
<div class="text-neutral-400">岩神瞳</div>
</div>
<div class="">
<div class="text-xl">180</div>
<div class="text-neutral-400">雷神瞳</div>
</div>
<div class="">
<div class="text-xl">79</div>
<div class="text-neutral-400">草神瞳</div>
</div>
</div>
</div>
<div>
<h2 class="box-title text-center text-xl ring text-neutral-100 p-1">
世界探索
</h2>
<div class="p-6 grid grid-cols-4 gap-4 text-center text-neutral-100">
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Xumi.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Xumi.png" />
<div class="text-sm w-full truncate">须弥</div>
<div class="text-xs">26.0%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 26.0%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_ChasmsMaw.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_ChasmsMaw.png" />
<div class="text-sm w-full truncate">层岩巨渊·地下矿区</div>
<div class="text-xs">98.7%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 98.7%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_ChasmsMaw.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_ChasmsMaw.png" />
<div class="text-sm w-full truncate">层岩巨渊</div>
<div class="text-xs">92.9%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 92.9%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Enkanomiya.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Enkanomiya.png" />
<div class="text-sm w-full truncate">渊下宫</div>
<div class="text-xs">98.3%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 98.3%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Daoqi.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Daoqi.png" />
<div class="text-sm w-full truncate">稻妻</div>
<div class="text-xs">100.0%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 100.0%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Dragonspine.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Dragonspine.png" />
<div class="text-sm w-full truncate">龙脊雪山</div>
<div class="text-xs">83.6%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 83.6%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Liyue.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Liyue.png" />
<div class="text-sm w-full truncate">璃月</div>
<div class="text-xs">95.9%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 95.9%"
></div>
</div>
</div>
<div
class="w-full flex flex-col items-center rounded-lg p-2 space-y-1 bg-cover"
style="background-image:
url('https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterCover_Mengde.png');"
>
<img class="w-3/5"
src="https://upload-bbs.mihoyo.com/game_record/genshin/city_icon/UI_ChapterIcon_Mengde.png" />
<div class="text-sm w-full truncate">蒙德</div>
<div class="text-xs">100.0%</div>
<div class="pointer-bar">
<div
class="pointer-progress-bar"
style="width: 100.0%"
></div>
</div>
</div>
</div>
</div>
<div class="about text-center leading-8 text-xs opacity-50">
所有数据会有一小时延迟 以游戏内为准 此处仅供参考
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,62 @@
:root {
--primary: #ffeabd;
}
body {
background-color: #f5f6fb;
}
.header {
background-image: url(./background/liyue.png);
box-shadow: 0 0 16px rgb(255 233 144 / 50%);
}
.box {
background-color: #9c433d;
box-shadow: 0 0 16px rgb(255 233 144 / 50%);
}
.box-title {
background-color: rgb(255, 200, 122, 0.1);
--tw-ring-color: #ff9966;
}
.pointer-bar {
width: 95%;
height: 8px;
display: inline-block;
background-color: rgb(0, 0, 0, 0.1);
border-radius: 0.25rem;
}
.pointer-progress-bar {
border-radius: 0.25rem;
height: 100%;
background: linear-gradient(to bottom, #f5efcd, #f8eabd, #ffdf90);
}
.name {
background: linear-gradient(to bottom, #ffffff, #ffeabd, #ffdf90);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.uid {
color: var(--primary);
background: linear-gradient(to right, rgb(0, 0, 0, 0), #cc6666, rgb(0, 0, 0, 0));
}
.about {
background-color: #e0dad3;
color: #8a4d30;
}
.box-stats {
color: var(--primary);
}
.box-stats-label {
color: var(--primary) !important;
opacity: 0.65;
}

View File

@ -0,0 +1,49 @@
body {
background-color: #f5f6fb;
}
.header {
background-image: url(background/mondstadt.png);
box-shadow: 0 0 8px rgb(123 242 248 / 50%);
}
.box {
background-color: #fdfdf3;
box-shadow: 0 0 8px rgb(123 242 248 / 50%);
}
.box-title {
background-color: #43849abb;
--tw-ring-color: #43849a;
}
.pointer-bar {
width: 95%;
height: 8px;
display: inline-block;
background-color: rgb(0, 0, 0, 0.2);
border-radius: 0.25rem;
}
.pointer-progress-bar {
border-radius: 0.25rem;
height: 100%;
background: #fff6e2;
}
.name {
background: linear-gradient(to bottom, #66bbee, #5ddddd, #55dddd);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.uid {
color: #fff;
background: linear-gradient(to right, rgb(0, 0, 0, 0), #5ddddd, rgb(0, 0, 0, 0));
}
.about {
background-color: #e0dad3;
color: #8a4d30;
}

View File

@ -0,0 +1,92 @@
body {
background-repeat: no-repeat;
background-size: cover;
background-position: center;
background-attachment: fixed;
}
#container {
}
.account-center-header {
padding: 10px 8px;
background-color: rgba(225, 225, 225, 0.5);
/*background-image: url("./0.jpg");
background-size: cover;*/
}
.user-avatar {
width: 100%;
height: 100%;
}
.user-info-1 {
padding: 10px 8px;
background-color: rgba(225, 225, 225, 0.5);
}
.world-exploration {
padding: 10px 8px;
background-color: rgba(225, 225, 225, 0.5);
}
.teapot {
padding: 10px 8px;
background-color: rgba(225, 225, 225, 0.5);
}
.account-center-header-avatar {
width: 120px;
height: 120px;
}
.teapot-info-icon {
height: 96px;
overflow: hidden;
}
.teapot-info-img {
width: 100%;
}
.world-exploration-info {
border: 2px solid rgb(0, 0, 0, 0.2);
}
.world-exploration-info-icon {
height: 96px;
overflow: hidden;
}
.world-exploration-info-img {
filter: brightness(0);
height: 100%;
}
.world-exploration-info-hr-1 {
width: 95%;
}
.world-exploration-info-pointer-bar-body {
width: 95%;
height: 4px;
display: inline-block;
border-radius: 2px;
background-color: rgb(0, 0, 0, 0.3);
}
.world-exploration-info-pointer-progress-bar {
height: 100%;
background-color: #000000;
border-radius: 2px;
}
.background-color {
background-color: rgb(225, 225, 225, 0.75);
}
.teapot-info-name {
background-color: rgba(225, 225, 225, 0.5);
}

View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8"/>
<title>Title</title>
<link type="text/css" href="../../styles/tailwind.min.css" rel="stylesheet">
<link type="text/css" href="./info.css" rel="stylesheet">
<link type="text/css" href="../../styles/public.css" rel="stylesheet">
<style>
body {
background-image: url({{background_image}});
}
</style>
</head>
<body>
<div class="background-color">
<div class="container mx-auto px-4 py-4" id="container">
<div class="account-center-header flex rounded-xl mb-4">
<div class="account-center-user p-4 flex-grow">
<div class="account-center-user-title pb-4">
<span class="account-center-user-name text-4xl">{{name}}</span>
</div>
<div class="account-center-user-uid pb-4">
<span class="account-center-user-name text-2xl">{{uid}}</span>
</div>
</div>
<div class="account-center-header-avatar mr-8 mt-2 flex-shrink">
<img class="user-avatar rounded-full " src="{{user_avatar}}">
</div>
</div>
<div class="user-info-1 rounded-xl text-center mb-4">
<div class="base-user-info-1 flex flex-wrap text-center">
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">活跃天数</div>
<div class="user-base-info-value text-4xl p-1 ">{{action_day_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">成就达成数</div>
<div class="user-base-info-value text-4xl p-1 ">{{achievement_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">获取角色数</div>
<div class="user-base-info-value text-4xl p-1 ">{{avatar_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">深境螺旋</div>
<div class="user-base-info-value text-4xl p-1 ">{{spiral_abyss}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">解锁传送点</div>
<div class="user-base-info-value text-4xl p-1 ">{{way_point_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">解锁秘境</div>
<div class="user-base-info-value text-4xl p-1 ">{{domain_number}}</div>
</div>
</div>
<div class="base-user-info-2 flex flex-wrap text-center">
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">奇馈宝箱数</div>
<div class="user-base-info-value text-4xl p-1 ">{{magic_chest_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">华丽宝箱数</div>
<div class="user-base-info-value text-4xl p-1 ">{{luxurious_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">珍贵宝箱数</div>
<div class="user-base-info-value text-4xl p-1 ">{{precious_chest_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">精致宝箱数</div>
<div class="user-base-info-value text-4xl p-1 ">{{exquisite_chest_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">普通宝箱数</div>
<div class="user-base-info-value text-4xl p-1 ">{{common_chest_number}}</div>
</div>
</div>
<div class="base-user-info-1 flex flex-wrap text-center">
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">风神瞳</div>
<div class="user-base-info-value text-4xl p-1 ">{{anemoculus_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">岩神瞳</div>
<div class="user-base-info-value text-4xl p-1 ">{{geoculus_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">雷神瞳</div>
<div class="user-base-info-value text-4xl p-1 ">{{electroculus_number}}</div>
</div>
<div class="user-base-info ml-4 p-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-xl p-1">草神瞳</div>
<div class="user-base-info-value text-4xl p-1 ">{{dendroculi_number}}</div>
</div>
</div>
</div>
<div class="user-info-2 flex ">
<div class="world-exploration rounded-xl float-left flex-1 mr-4">
<div class="world-exploration-title pl-2 ">
<h1 class="text-2xl">世界探索</h1>
</div>
<div class="world-exploration-list pt-2 px-4">
{% for world_exploration in world_exploration_list %}
<div class="world-exploration-info mt-2 flex rounded-xl">
<div class="world-exploration-info-icon flex-shrink">
<img class="world-exploration-info-img" src="{{world_exploration.icon}}">
</div>
<div class="world-exploration-info-info flex-grow pt-1 pl-2 pb-1">
<div class="world-exploration-info-name pb-1">
<p>{{world_exploration.name}}</p>
</div>
<div class="world-exploration-info-hr pb-1">
<HR class="world-exploration-info-hr-1" color=#000000 style="height: 2px">
</div>
<div class="world-exploration-info-s">
<p>探索度:{{world_exploration.exploration_percentage}}%</p>
</div>
<div class="world-exploration-info-pointer">
<div class="world-exploration-info-pointer-bar-body">
<div class="world-exploration-info-pointer-progress-bar"
style="width: {{world_exploration.exploration_percentage}}%"></div>
</div>
</div>
<div class="world-exploration-info-offerings-list flex">
{% for offering in world_exploration.offerings %}
<div class="world-exploration-info-other-1 flex-1">
<p>{{offering.data}}</p>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="teapot rounded-xl float-right flex-1">
<div class="teapot-title pl-2">
<h1 class="text-2xl">尘歌壶</h1>
</div>
<div class="teapot-info-base flex rounded-xl text-center pt-2">
<div class="user-base-info py-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-base p-1">信任等阶级</div>
<div class="user-base-info-value text-4xl p-1">{{teapot_level}}</div>
</div>
<div class="user-base-info py-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-base p-1">最高洞天仙力</div>
<div class="user-base-info-value text-4xl p-1">{{teapot_comfort_num}}</div>
</div>
<div class="user-base-info py-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-base p-1">获得摆设数</div>
<div class="user-base-info-value text-4xl p-1">{{teapot_item_num}}</div>
</div>
<div class="user-base-info py-2 rounded-xl flex-1">
<div class="user-base-info-title-name text-base p-1">历史访问数</div>
<div class="user-base-info-value text-4xl p-1">{{teapot_visit_num}}</div>
</div>
</div>
<div class="teapot-info-title text-center pt-2">
<h1 class="text-base">已解锁的洞天</h1>
</div>
<div class="teapot-info-list flex-col px-4">
{% for teapot in teapot_list %}
<div class="teapot-info pt-4">
<div class="teapot-info-icon rounded-xl relative">
<div class="teapot-info-icon">
<img class="teapot-info-img" src="{{teapot.icon}}">
</div>
<div class="teapot-info-name absolute right-0 top-0 px-2 text-base rounded-bl-lg">
{{teapot.name}}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="./{{style}}.css" rel="stylesheet" />
<link type="text/css" href="../../styles/public.css" rel="stylesheet" />
<script src="../../js/tailwindcss-3.1.8.js"></script>
</head>
<body class="text-neutral-600">
<div class="mx-auto max-w-[600px] py-8">
<div class="header p-6 flex mb-8 rounded-xl bg-cover justify-between">
<div class="flex flex-col items-center justify-center">
<h1 class="text-4xl italic name mb-2 px-2">
{{ nickname }}
</h1>
<h1 class="italic uid px-10">UID - {{ uid }}</h1>
</div>
</div>
<div class="box pt-4 rounded-xl overflow-hidden">
<div>
<h2 class="box-title text-center text-xl ring text-neutral-100 p-1">
数据总览
</h2>
<div class="p-6 grid grid-cols-4 gap-4 text-center">
{% for label, key in stats_labels %}
<div class="">
<div class="text-xl box-stats">{{ stats[key] }}</div>
<div class="text-neutral-400 box-stats-label">{{ label }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="about text-center leading-8 text-xs opacity-50">
所有数据会有一小时延迟 以游戏内为准 此处仅供参考
</div>
</div>
</div>
</body>
</html>

View File

@ -4,6 +4,8 @@ import re
def mask_number(number): def mask_number(number):
number_str = str(number) number_str = str(number)
masked_number = None masked_number = None
if len(number_str) == 8:
masked_number = re.sub(r"(\d{2})(\d{3})(\d{3})", r"\1***\3", number_str)
if len(number_str) == 9: if len(number_str) == 9:
masked_number = re.sub(r"(\d{2})(\d{4})(\d{3})", r"\1****\3", number_str) masked_number = re.sub(r"(\d{2})(\d{4})(\d{3})", r"\1****\3", number_str)
if len(number_str) == 10: if len(number_str) == 10: