Update /play_card placeholder image

Update current player showcase after updating character list.
Use asynchronous locks to make file read/write thread-safe.
Update `EnkaNetworkCache`.

---------

Co-authored-by: 洛水居室 <luoshuijs@outlook.com>
This commit is contained in:
LittleMengBot 2023-03-17 13:33:29 +08:00 committed by GitHub
parent b60094eef0
commit 72154924be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 463 additions and 58 deletions

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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 [

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="zh-ch">
<head>
<meta charset="UTF-8">
<title>holder</title>
<link type="text/css" href="./style.css" rel="stylesheet"/>
<link type="text/css" href="../../styles/public.css" rel="stylesheet"/>
</head>
<body>
<div class="overview">
<div class="title">角色展柜</div>
<div class="summarize">
<div>
<div>UID: {{ uid }}</div>
<div>冒险等阶: {{ level }} 级</div>
</div>
<div>
<div>签名: {{ signature }}</div>
</div>
</div>
<div class="characters">
{% for character in characters %}
<div class="character">
{% if character.constellation > 0 %}
{% set bg = ['blue','blue', 'green','green', 'red', 'red'][character.constellation - 1] %}
<div style="background-color: var(--{{ bg }})">{{ character.constellation }} 命</div>
{% endif %}
<div class="element" style="background-image: url('../../img/element/{{ character.element }}.png')"></div>
<div class="icon" style="background-image: url('../../background/rarity/half/{{ character.rarity }}.png')">
<img src="{{ character.icon }}" alt=""/>
</div>
<div class="caption">Lv.{{ character.level }}</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-ch">
<head>
<meta charset="UTF-8">
<title>holder_example</title>
<link type="text/css" href="./style.css" rel="stylesheet"/>
<link type="text/css" href="../../styles/public.css" rel="stylesheet"/>
</head>
<body>
<div class="overview">
<div class="title">角色展柜</div>
<div class="summarize">
<div>
<div>UID: 123456789</div>
<div>冒险等阶: 55</div>
</div>
<div>
<div>签名: 貴方の運命は、すでに我が手中の糸が絡めとった!填充</div>
</div>
</div>
<div class="characters">
<div class="character">
<div style="background-color: var(--green)">4命</div>
<div class="element" style="background-image: url('../../img/element/Cryo.png')"></div>
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
<div class="caption">Lv.90</div>
</div>
<div class="character">
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
</div>
<div class="character">
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
</div>
<div class="character">
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
</div>
<div class="character">
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
</div>
<div class="character">
<div class="icon" style="background-image: url('../../background/rarity/half/5.png')">
<img src="../../assets/avatar/10000007/icon.png" alt="荧"/>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View File

@ -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;
}

View File

@ -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)