diff --git a/core/services/players/services.py b/core/services/players/services.py index 5eaf812..1397b91 100644 --- a/core/services/players/services.py +++ b/core/services/players/services.py @@ -63,7 +63,7 @@ class PlayerInfoService(BaseService): self.cache = redis.client 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", ttl=60)) + self.enka_client.set_cache(RedisCache(redis.client, key="players_info:enka_network", ex=60)) self.qname = "players_info" async def get_form_cache(self, player: Player): diff --git a/modules/playercards/file.py b/modules/playercards/file.py index 11d97a5..51d8705 100644 --- a/modules/playercards/file.py +++ b/modules/playercards/file.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from typing import Optional, Dict, Union @@ -5,13 +6,19 @@ import aiofiles from utils.const import PROJECT_ROOT -import ujson as jsonlib +try: + import ujson as jsonlib +except ImportError: + import json as jsonlib + PLAYER_CARDS_PATH = PROJECT_ROOT.joinpath("data", "apihelper", "player_cards") PLAYER_CARDS_PATH.mkdir(parents=True, exist_ok=True) class PlayerCardsFile: + _lock = asyncio.Lock() + def __init__(self, player_cards_path: Path = PLAYER_CARDS_PATH): self.player_cards_path = player_cards_path @@ -53,14 +60,15 @@ class PlayerCardsFile: uid: Union[str, int], data: Dict, ) -> Dict: - old_data = await self.load_history_info(uid) - if old_data is None: + async with self._lock: + old_data = await self.load_history_info(uid) + if old_data is None: + await self.save_json(self.get_file_path(uid), data) + return data + data["avatarInfoList"] = data.get("avatarInfoList", []) + characters = [i.get("avatarId", 0) for i in data["avatarInfoList"]] + for i in old_data.get("avatarInfoList", []): + if i.get("avatarId", 0) not in characters: + data["avatarInfoList"].append(i) await self.save_json(self.get_file_path(uid), data) return data - data["avatarInfoList"] = data.get("avatarInfoList", []) - characters = [i.get("avatarId", 0) for i in data["avatarInfoList"]] - for i in old_data.get("avatarInfoList", []): - if i.get("avatarId", 0) not in characters: - data["avatarInfoList"].append(i) - await self.save_json(self.get_file_path(uid), data) - return data diff --git a/plugins/genshin/avatar_list.py b/plugins/genshin/avatar_list.py index b251aa0..12a6f16 100644 --- a/plugins/genshin/avatar_list.py +++ b/plugins/genshin/avatar_list.py @@ -67,7 +67,7 @@ class AvatarListPlugin(Plugin): self.assets_service = assets_service self.template_service = template_service self.enka_client = EnkaNetworkAPI(lang="chs", user_agent=config.enka_network_api_agent) - self.enka_client.set_cache(RedisCache(redis.client, key="plugin:avatar_list:enka_network", ttl=60 * 60 * 3)) + self.enka_client.set_cache(RedisCache(redis.client, key="plugin:avatar_list:enka_network", ex=60 * 60 * 3)) self.enka_assets = EnkaAssets(lang="chs") self.helper = helper self.character_details = character_details diff --git a/plugins/genshin/player_cards.py b/plugins/genshin/player_cards.py index 2c473ba..7223fb3 100644 --- a/plugins/genshin/player_cards.py +++ b/plugins/genshin/player_cards.py @@ -1,14 +1,12 @@ import math -from typing import Any, List, Tuple, Union, Optional +from typing import Any, List, Tuple, Union, Optional, TYPE_CHECKING from enkanetwork import ( - CharacterInfo, DigitType, EnkaNetworkAPI, EnkaNetworkResponse, EnkaServerError, Equipments, - EquipmentsStats, EquipmentsType, HTTPException, Stats, @@ -20,13 +18,13 @@ from enkanetwork import ( EnkaPlayerNotFound, ) from pydantic import BaseModel -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup from telegram.constants import ChatAction -from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters +from telegram.ext import CommandHandler, MessageHandler, filters from telegram.helpers import create_deep_linked_url from core.config import config -from core.dependence.assets import DEFAULT_EnkaAssets +from core.dependence.assets import DEFAULT_EnkaAssets, AssetsService from core.dependence.redisdb import RedisDB from core.handler.callbackqueryhandler import CallbackQueryHandler from core.plugin import Plugin, handler @@ -40,6 +38,11 @@ from utils.helpers import download_resource from utils.log import logger from utils.patch.aiohttp import AioHttpTimeoutException +if TYPE_CHECKING: + from enkanetwork import CharacterInfo, EquipmentsStats + from telegram.ext import ContextTypes + from telegram import Update + try: import ujson as jsonlib except ImportError: @@ -47,15 +50,22 @@ except ImportError: class PlayerCards(Plugin): - def __init__(self, player_service: PlayersService, template_service: TemplateService, redis: RedisDB): + 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") + self.cache = RedisCache(redis.client, key="plugin:player_cards:enka_network", ex=60) self.player_cards_file = PlayerCardsFile() + self.assets_service = assets_service self.template_service = template_service - self.temp_photo: Optional[str] = None + self.kitsune: Optional[str] = None - async def _fetch_user(self, uid) -> Union[EnkaNetworkResponse, str]: + async def _update_enka_data(self, uid) -> Union[EnkaNetworkResponse, str]: try: data = await self.cache.get(uid) if data is not None: @@ -82,25 +92,35 @@ class PlayerCards(Plugin): error = "未找到玩家,请检查您的UID/用户名" except HTTPException: error = "Enka.Network HTTP 服务请求错误,请稍后重试" - old_data = await self.player_cards_file.load_history_info(uid) - if old_data is not None: - logger.warning("UID %s | 角色卡片使用历史数据 | %s", uid, error) - return EnkaNetworkResponse.parse_obj(old_data) return error + async def _load_history(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) + @handler(CommandHandler, command="player_card", block=False) @handler(MessageHandler, filters=filters.Regex("^角色卡片查询(.*)"), block=False) - async def player_cards(self, update: Update, context: CallbackContext) -> None: + async def player_cards(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None: user = update.effective_user message = update.effective_message args = self.get_args(context) await message.reply_chat_action(ChatAction.TYPING) player_info = await self.player_service.get_player(user.id) if player_info is None: - buttons = [[InlineKeyboardButton("点我绑定账号", url=create_deep_linked_url(context.bot.username, "set_uid"))]] + buttons = [ + [ + InlineKeyboardButton( + "点我绑定账号", + url=create_deep_linked_url(context.bot.username, "set_uid"), + ) + ] + ] if filters.ChatType.GROUPS.filter(message): reply_message = await message.reply_text( - "未查询到您所绑定的账号信息,请先私聊派蒙绑定账号", reply_markup=InlineKeyboardMarkup(buttons) + "未查询到您所绑定的账号信息,请先私聊派蒙绑定账号", + reply_markup=InlineKeyboardMarkup(buttons), ) self.add_delete_message_job(reply_message, delay=30) @@ -108,12 +128,27 @@ class PlayerCards(Plugin): else: await message.reply_text("未查询到您所绑定的账号信息,请先绑定账号", reply_markup=InlineKeyboardMarkup(buttons)) return - data = await self._fetch_user(player_info.player_id) - if isinstance(data, str): - await message.reply_text(data) - return - if data.characters is None: - await message.reply_text("请在游戏中的角色展柜中添加角色再开启显示角色详情再使用此功能,如果已经添加了角色,请等待角色数据更新后重试") + data = await self._load_history(player_info.player_id) + if data is None: + 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}|{player_info.player_id}", + ) + ] + ] + reply_message = await message.reply_photo( + photo=photo, + caption="角色列表未找到,请尝试点击下方按钮从 EnkaNetwork 更新角色列表", + reply_markup=InlineKeyboardMarkup(buttons), + ) + if reply_message.photo: + self.kitsune = reply_message.photo[-1].file_id return if len(args) == 1: character_name = roleToName(args[0]) @@ -126,16 +161,20 @@ class PlayerCards(Plugin): ) else: logger.info("用户 %s[%s] 角色卡片查询命令请求", user.full_name, user.id) - buttons = self.gen_button(data, user.id, player_info.player_id) - if isinstance(self.temp_photo, str): - photo = self.temp_photo + ttl = await self.cache.ttl(player_info.player_id) + + buttons = self.gen_button(data, user.id, player_info.player_id, 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) + photo=photo, + caption="请选择你要查询的角色", + reply_markup=InlineKeyboardMarkup(buttons), ) if reply_message.photo: - self.temp_photo = reply_message.photo[-1].file_id + self.kitsune = reply_message.photo[-1].file_id return for characters in data.characters: if characters.name == character_name: @@ -147,20 +186,71 @@ class PlayerCards(Plugin): render_result = await RenderTemplate( player_info.player_id, characters, self.template_service ).render() # pylint: disable=W0631 - await render_result.reply_photo(message, filename=f"player_card_{player_info.player_id}_{character_name}.png") + await render_result.reply_photo( + message, + filename=f"player_card_{player_info.player_id}_{character_name}.png", + ) + + @handler(CallbackQueryHandler, 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) + await callback_query.answer(text="正在从 EnkaNetwork 获取角色列表 请不要重复点击按钮") + data = await self._update_enka_data(uid) + if isinstance(data, str): + await callback_query.answer(text=data, show_alert=True) + return + 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.html", + render_data, + viewport={"width": 750, "height": 580}, + ttl=60 * 10, + caption="更新角色列表成功,请选择你要查询的角色", + ) + await holder.edit_media(message, reply_markup=InlineKeyboardMarkup(buttons)) @handler(CallbackQueryHandler, pattern=r"^get_player_card\|", block=False) - async def get_player_cards(self, update: Update, _: CallbackContext) -> None: + 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]: + 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) + 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) @@ -173,10 +263,22 @@ class PlayerCards(Plugin): page = 0 if result.isdigit(): page = int(result) - logger.info("用户 %s[%s] 角色卡片查询命令请求 || page[%s] uid[%s]", user.full_name, user.id, page, uid) + 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) - data = await self._fetch_user(uid) + logger.info( + "用户 %s[%s] 角色卡片查询命令请求 || character_name[%s] uid[%s]", + user.full_name, + user.id, + result, + uid, + ) + data = await self._load_history(uid) if isinstance(data, str): await message.reply_text(data) return @@ -185,7 +287,7 @@ class PlayerCards(Plugin): await callback_query.answer("请先将角色加入到角色展柜并允许查看角色详情后再使用此功能,如果已经添加了角色,请等待角色数据更新后重试", show_alert=True) return if page: - buttons = self.gen_button(data, user.id, uid, page) + buttons = self.gen_button(data, user.id, uid, page, not 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 @@ -208,6 +310,7 @@ class PlayerCards(Plugin): user_id: Union[str, int], uid: int, page: int = 1, + update_button: bool = True, ) -> List[List[InlineKeyboardButton]]: """生成按钮""" buttons = [ @@ -238,6 +341,13 @@ class PlayerCards(Plugin): 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( @@ -249,6 +359,30 @@ class PlayerCards(Plugin): 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": data.uid, + "level": data.player.level, + "signature": data.player.signature, + "characters": characters_data, + } + class Artifact(BaseModel): """在 enka Equipments model 基础上扩展了圣遗物评分数据""" @@ -301,7 +435,12 @@ class Artifact(BaseModel): class RenderTemplate: - def __init__(self, uid: Union[int, str], character: CharacterInfo, template_service: TemplateService = None): + def __init__( + self, + uid: Union[int, str], + character: "CharacterInfo", + template_service: TemplateService = None, + ): self.uid = uid self.template_service = template_service # 因为需要替换线上 enka 图片地址为本地地址,先克隆数据,避免修改原数据 @@ -443,7 +582,7 @@ class RenderTemplate: stats = ArtifactStatsTheory(self.character.name) - def substat_score(s: EquipmentsStats) -> float: + def substat_score(s: "EquipmentsStats") -> float: return stats.theory(s) return [ diff --git a/resources/genshin/player_card/holder.html b/resources/genshin/player_card/holder.html new file mode 100644 index 0000000..74281c7 --- /dev/null +++ b/resources/genshin/player_card/holder.html @@ -0,0 +1,39 @@ + + + + + holder + + + + +
+
角色展柜
+
+
+
UID: {{ uid }}
+
冒险等阶: {{ level }} 级
+
+
+
签名: {{ signature }}
+
+
+
+ {% for character in characters %} +
+ {% if character.constellation > 0 %} + {% set bg = ['blue','blue', 'green','green', 'red', 'red'][character.constellation - 1] %} +
{{ character.constellation }} 命
+ {% endif %} +
+
+ +
+
Lv.{{ character.level }}
+
+ {% endfor %} + +
+
+ + \ No newline at end of file diff --git a/resources/genshin/player_card/holder_example.html b/resources/genshin/player_card/holder_example.html new file mode 100644 index 0000000..01bcc43 --- /dev/null +++ b/resources/genshin/player_card/holder_example.html @@ -0,0 +1,58 @@ + + + + + holder_example + + + + +
+
角色展柜
+
+
+
UID: 123456789
+
冒险等阶: 55
+
+
+
签名: 貴方の運命は、すでに我が手中の糸が絡めとった!填充
+
+
+
+
+
4命
+
+
+ 荧 +
+
Lv.90
+
+
+
+ 荧 +
+
+
+
+ 荧 +
+
+
+
+ 荧 +
+
+
+
+ 荧 +
+
+
+
+ 荧 +
+
+
+
+ + \ No newline at end of file diff --git a/resources/genshin/player_card/img/holder_bg.png b/resources/genshin/player_card/img/holder_bg.png new file mode 100644 index 0000000..2b4fece Binary files /dev/null and b/resources/genshin/player_card/img/holder_bg.png differ diff --git a/resources/genshin/player_card/style.css b/resources/genshin/player_card/style.css new file mode 100644 index 0000000..98703a3 --- /dev/null +++ b/resources/genshin/player_card/style.css @@ -0,0 +1,146 @@ +:root { + --white: rgb(246 248 249); + --bg-color: rgb(233 229 220); + --h-color: rgb(203 189 162); + --red: rgb(255 86 33/ 80%); + --blue: rgb(98 168 233/ 80%); + --green: rgb(67 185 124/ 80%); +} + +body { + margin: 0; + padding: 5px; +} + +.hr { + width: 100%; + height: 3px; + background-color: rgb(246 248 249 / 50%); +} + +.container { + width: 750px; + position: relative; + filter: drop-shadow(2px 2px 5px rgb(0 0 0 /70%)); +} + +.title { + text-align: center; + font-size: 27px; + font-weight: bold; + color: var(--h-color); +} + +.caption { + margin: 10px 0; + color: var(--h-color); + font-size: 20px; +} + +/* 概览 */ + +.overview { + height: 540px; + padding: 20px 30px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + background-image: linear-gradient(to top, rgb(0 0 0 / 10%), rgb(0 0 0 / 10%)), url("./img/holder_bg.png"); + background-attachment: local; + border-radius: 15px; + overflow: hidden; +} + +.summarize { + font-size: 20px; + margin: 10px; + padding: 20px; + border-radius: 5px; + border: 2px solid rgb(118 121 120 / 80%); + outline: 4px solid rgb(70, 80, 100); + background-color: rgb(70 80 100 / 60%); + background-image: url("../abyss/background/banner 01.png"), url("../abyss/background/banner 02.png"); + background-repeat: no-repeat, no-repeat; + background-position: right, left; + background-size: auto 100%, auto 100%; + backdrop-filter: blur(5px); +} + +.summarize > div { + width: 100%; + height: 50%; + padding: 5px; + color: var(--white); + display: flex; + align-items: center; +} + +.summarize > div > div { + flex: 1; +} + +.characters { + margin-left: 47px; + margin-top: 15px; + display: flex; + flex-wrap: wrap; +} + +.character { + width: 120px; + height: 150px; + margin: 15px 12px; + background-color: rgb(233 229 220); + overflow: hidden; + border-radius: 10px; + position: relative; +} + +.characters > .character > .element { + position: absolute; + top: 3px; + left: 3px; + width: 25px; + height: 25px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.icon { + width: 100%; + height: 120px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + overflow: hidden; + border-radius: 0 0 20px 0; +} + +.character > .caption { + font-size: 16px; + margin: 4px 0 0; + padding: 0; + height: min-content; + text-align: center; + color: black; +} + + +.character > div:first-child:not(.icon, .element) { + position: absolute; + top: 0; + right: 0; + padding: 3px; + min-width: 27px; + text-align: center; + border-radius: 0 0 0 10px; + filter: drop-shadow(1px 1px 5px rgb(0 0 0/50%)); + font-weight: 500; + color: var(--white); +} + +.icon > img { + width: inherit; + height: inherit; +} diff --git a/utils/enkanetwork.py b/utils/enkanetwork.py index 6c9f033..50c47aa 100644 --- a/utils/enkanetwork.py +++ b/utils/enkanetwork.py @@ -1,16 +1,23 @@ -import json -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, TYPE_CHECKING from enkanetwork import Cache -from redis import asyncio as aioredis + +try: + import ujson as jsonlib +except ImportError: + import json as jsonlib + + +if TYPE_CHECKING: + from redis import asyncio as aioredis __all__ = ("RedisCache",) class RedisCache(Cache): - def __init__(self, redis: aioredis.Redis, key: Optional[str] = None, ttl: int = 60 * 3) -> None: + def __init__(self, redis: "aioredis.Redis", key: Optional[str] = None, ex: int = 60 * 3) -> None: self.redis = redis - self.ttl = ttl + self.ex = ex self.key = key def get_qname(self, key): @@ -21,10 +28,18 @@ class RedisCache(Cache): data = await self.redis.get(qname) if data: json_data = str(data, encoding="utf-8") - return json.loads(json_data) + return jsonlib.loads(json_data) return None async def set(self, key, value) -> None: qname = self.get_qname(key) - data = json.dumps(value) - await self.redis.set(qname, data, ex=self.ttl) + data = jsonlib.dumps(value) + await self.redis.set(qname, data, ex=self.ex) + + async def exists(self, key) -> int: + qname = self.get_qname(key) + return await self.redis.exists(qname) + + async def ttl(self, key) -> int: + qname = self.get_qname(key) + return await self.redis.ttl(qname)