From f122e21092123bc79684b87d4b8a651f2ec7adcd Mon Sep 17 00:00:00 2001 From: Chuangbo Li Date: Sat, 22 Oct 2022 15:03:59 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E5=A2=9E=E5=8A=A0=20html=20to=20im?= =?UTF-8?q?age=20=E7=9A=84=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增加 html to image 缓存 * 对 template_service.render 进行封装,管理缓存逻辑 * cache key 为 html 的 sha256 * cache value 为 reply_photo 后 telegram 返回的 file_id * 存入 redis,并设置合理的 ttl Co-authored-by: 洛水居室 Co-authored-by: xtaodada --- core/template/__init__.py | 8 +- core/template/cache.py | 32 +++++++- core/template/error.py | 14 ++++ core/template/models.py | 126 +++++++++++++++++++++++++++++ core/template/services.py | 77 +++++++++++++----- plugins/genshin/abyss.py | 82 ++++++++++--------- plugins/genshin/abyss_team.py | 4 +- plugins/genshin/avatar_list.py | 31 +++---- plugins/genshin/daily/material.py | 55 ++++++------- plugins/genshin/daily_note.py | 14 ++-- plugins/genshin/gacha/gacha_log.py | 17 ++-- plugins/genshin/help.py | 25 ++---- plugins/genshin/ledger.py | 12 +-- plugins/genshin/player_cards.py | 48 +++++------ plugins/genshin/userstats.py | 7 +- plugins/genshin/weapon.py | 8 +- 16 files changed, 384 insertions(+), 176 deletions(-) create mode 100644 core/template/error.py create mode 100644 core/template/models.py diff --git a/core/template/__init__.py b/core/template/__init__.py index 91b5ec4a..d6c695fe 100644 --- a/core/template/__init__.py +++ b/core/template/__init__.py @@ -2,10 +2,12 @@ from core.base.aiobrowser import AioBrowser from core.service import init_service from core.base.redisdb import RedisDB from core.template.services import TemplateService -from core.template.cache import TemplatePreviewCache +from core.template.cache import TemplatePreviewCache, HtmlToFileIdCache + @init_service def create_template_service(browser: AioBrowser, redis: RedisDB): - _cache = TemplatePreviewCache(redis) - _service = TemplateService(browser, _cache) + _preview_cache = TemplatePreviewCache(redis) + _html_to_file_id_cache = HtmlToFileIdCache(redis) + _service = TemplateService(browser, _html_to_file_id_cache, _preview_cache) return _service diff --git a/core/template/cache.py b/core/template/cache.py index 4f2cf018..5df3529f 100644 --- a/core/template/cache.py +++ b/core/template/cache.py @@ -1,12 +1,13 @@ -from typing import Any -import pickle # nosec B403 import gzip +import pickle # nosec B403 +from hashlib import sha256 +from typing import Any, Optional from core.base.redisdb import RedisDB class TemplatePreviewCache: - '''暂存渲染模板的数据用于预览''' + """暂存渲染模板的数据用于预览""" def __init__(self, redis: RedisDB): self.client = redis.client @@ -16,7 +17,7 @@ class TemplatePreviewCache: data = await self.client.get(self.cache_key(key)) if data: # skipcq: BAN-B301 - return pickle.loads(gzip.decompress(data)) # nosec B301 + return pickle.loads(gzip.decompress(data)) # nosec B301 async def set_data(self, key: str, data: Any, ttl: int = 8 * 60 * 60): ck = self.cache_key(key) @@ -26,3 +27,26 @@ class TemplatePreviewCache: def cache_key(self, key: str) -> str: return f"{self.qname}:{key}" + + +class HtmlToFileIdCache: + """html to file_id 的缓存""" + + def __init__(self, redis: RedisDB): + self.client = redis.client + self.qname = "bot:template:html-to-file-id" + + async def get_data(self, html: str, file_type: str) -> Optional[str]: + data = await self.client.get(self.cache_key(html, file_type)) + if data: + return data.decode() + + async def set_data(self, html: str, file_type: str, file_id: str, ttl: int = 24 * 60 * 60): + ck = self.cache_key(html, file_type) + await self.client.set(ck, file_id) + if ttl != -1: + await self.client.expire(ck, ttl) + + def cache_key(self, html: str, file_type: str) -> str: + key = sha256(html.encode()).hexdigest() + return f"{self.qname}:{file_type}:{key}" diff --git a/core/template/error.py b/core/template/error.py new file mode 100644 index 00000000..197e06cb --- /dev/null +++ b/core/template/error.py @@ -0,0 +1,14 @@ +class TemplateException(Exception): + pass + + +class QuerySelectorNotFound(TemplateException): + pass + + +class ErrorFileType(TemplateException): + pass + + +class FileIdNotFound(TemplateException): + pass diff --git a/core/template/models.py b/core/template/models.py new file mode 100644 index 00000000..b6cbdfd6 --- /dev/null +++ b/core/template/models.py @@ -0,0 +1,126 @@ +from enum import Enum +from typing import Optional, Union, List + +from telegram import Message, InputMediaPhoto, InputMediaDocument + +from core.template.cache import HtmlToFileIdCache +from core.template.error import ErrorFileType, FileIdNotFound + + +class FileType(Enum): + PHOTO = 1 + DOCUMENT = 2 + + @staticmethod + def media_type(file_type: "FileType"): + """对应的 Telegram media 类型""" + if file_type == FileType.PHOTO: + return InputMediaPhoto + elif file_type == FileType.DOCUMENT: + return InputMediaDocument + else: + raise ErrorFileType + + +class RenderResult: + """渲染结果""" + + def __init__( + self, + html: str, + photo: Union[bytes, str], + file_type: FileType, + cache: HtmlToFileIdCache, + ttl: int = 24 * 60 * 60, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + filename: Optional[str] = None, + ): + """ + `html`: str 渲染生成的 html + `photo`: Union[bytes, str] 渲染生成的图片。bytes 表示是图片,str 则为 file_id + """ + self.caption = caption + self.parse_mode = parse_mode + self.filename = filename + self.html = html + self.photo = photo + self.file_type = file_type + self._cache = cache + self.ttl = ttl + + async def reply_photo(self, message: Message, *args, **kwargs): + """是 `message.reply_photo` 的封装,上传成功后,缓存 telegram 返回的 file_id,方便重复使用""" + if self.file_type != FileType.PHOTO: + raise ErrorFileType + + reply = await message.reply_photo(photo=self.photo, *args, **kwargs) + + await self.cache_file_id(reply) + + return reply + + async def reply_document(self, message: Message, *args, **kwargs): + """是 `message.reply_document` 的封装,上传成功后,缓存 telegram 返回的 file_id,方便重复使用""" + if self.file_type != FileType.DOCUMENT: + raise ErrorFileType + + reply = await message.reply_document(document=self.photo, *args, **kwargs) + + await self.cache_file_id(reply) + + return reply + + async def edit_media(self, message: Message, *args, **kwargs): + """是 `message.edit_media` 的封装,上传成功后,缓存 telegram 返回的 file_id,方便重复使用""" + if self.file_type != FileType.PHOTO: + raise ErrorFileType + + media = InputMediaPhoto( + media=self.photo, caption=self.caption, parse_mode=self.parse_mode, filename=self.filename + ) + + edit_media = await message.edit_media(media, *args, **kwargs) + + await self.cache_file_id(edit_media) + + return edit_media + + async def cache_file_id(self, reply: Message): + """缓存 telegram 返回的 file_id""" + if self.is_file_id(): + return + + if self.file_type == FileType.PHOTO and reply.photo: + file_id = reply.photo[0].file_id + elif self.file_type == FileType.DOCUMENT and reply.document: + file_id = reply.document.file_id + else: + raise FileIdNotFound + await self._cache.set_data(self.html, self.file_type.name, file_id, self.ttl) + + def is_file_id(self) -> bool: + return isinstance(self.photo, str) + + +class RenderGroupResult: + def __init__(self, results: List[RenderResult]): + self.results = results + + async def reply_media_group(self, message: Message, *args, **kwargs): + """是 `message.reply_media_group` 的封装,上传成功后,缓存 telegram 返回的 file_id,方便重复使用""" + + reply = await message.reply_media_group( + media=[ + FileType.media_type(result.file_type)( + media=result.photo, caption=result.caption, parse_mode=result.parse_mode, filename=result.filename + ) + for result in self.results + ], + *args, + **kwargs, + ) + + for index, value in enumerate(reply): + result = self.results[index] + await result.cache_file_id(value) diff --git a/core/template/services.py b/core/template/services.py index 57ab0066..592f70f8 100644 --- a/core/template/services.py +++ b/core/template/services.py @@ -1,29 +1,32 @@ import time from typing import Optional from urllib.parse import urlencode, urljoin, urlsplit - -from jinja2 import Environment, FileSystemLoader, Template -from playwright.async_api import ViewportSize from uuid import uuid4 from fastapi import HTTPException from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader, Template +from playwright.async_api import ViewportSize from core.base.aiobrowser import AioBrowser -from core.bot import bot from core.base.webserver import webapp +from core.bot import bot +from core.template.cache import HtmlToFileIdCache, TemplatePreviewCache +from core.template.error import QuerySelectorNotFound +from core.template.models import FileType, RenderResult from utils.const import PROJECT_ROOT from utils.log import logger -from core.template.cache import TemplatePreviewCache - - -class _QuerySelectorNotFound(Exception): - pass class TemplateService: - def __init__(self, browser: AioBrowser, preview_cache: TemplatePreviewCache, template_dir: str = "resources"): + def __init__( + self, + browser: AioBrowser, + html_to_file_id_cache: HtmlToFileIdCache, + preview_cache: TemplatePreviewCache, + template_dir: str = "resources", + ): self._browser = browser self.template_dir = PROJECT_ROOT / template_dir @@ -36,10 +39,12 @@ class TemplateService: self.previewer = TemplatePreviewer(self, preview_cache) + self.html_to_file_id_cache = html_to_file_id_cache + def get_template(self, template_name: str) -> Template: return self._jinja2_env.get_template(template_name) - async def render_async(self, template_name: str, template_data: dict): + async def render_async(self, template_name: str, template_data: dict) -> str: """模板渲染 :param template_name: 模板文件名 :param template_data: 模板数据 @@ -54,19 +59,28 @@ class TemplateService: self, template_name: str, template_data: dict, - viewport: ViewportSize = None, + viewport: Optional[ViewportSize] = None, full_page: bool = True, evaluate: Optional[str] = None, - query_selector: str = None - ) -> bytes: + query_selector: Optional[str] = None, + file_type: FileType = FileType.PHOTO, + ttl: int = 24 * 60 * 60, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + filename: Optional[str] = None, + ) -> RenderResult: """模板渲染成图片 - :param template_path: 模板目录 :param template_name: 模板文件名 :param template_data: 模板数据 :param viewport: 截图大小 :param full_page: 是否长截图 :param evaluate: 页面加载后运行的 js :param query_selector: 截图选择器 + :param file_type: 缓存的文件类型 + :param ttl: 缓存时间 + :param caption: 图片描述 + :param parse_mode: 图片描述解析模式 + :param filename: 文件名字 :return: """ start_time = time.time() @@ -79,6 +93,20 @@ class TemplateService: html = await template.render_async(**template_data) logger.debug(f"{template_name} 模板渲染使用了 {str(time.time() - start_time)}") + file_id = await self.html_to_file_id_cache.get_data(html, file_type.name) + if file_id and not bot.config.debug: + logger.debug(f"{template_name} 命中缓存,返回 file_id {file_id}") + return RenderResult( + html=html, + photo=file_id, + file_type=file_type, + cache=self.html_to_file_id_cache, + ttl=ttl, + caption=caption, + parse_mode=parse_mode, + filename=filename, + ) + browser = await self._browser.get_browser() start_time = time.time() page = await browser.new_page(viewport=viewport) @@ -92,16 +120,25 @@ class TemplateService: try: card = await page.query_selector(query_selector) if not card: - raise _QuerySelectorNotFound + raise QuerySelectorNotFound clip = await card.bounding_box() if not clip: - raise _QuerySelectorNotFound - except _QuerySelectorNotFound: + raise QuerySelectorNotFound + except QuerySelectorNotFound: logger.warning(f"未找到 {query_selector} 元素") png_data = await page.screenshot(clip=clip, full_page=full_page) await page.close() logger.debug(f"{template_name} 图片渲染使用了 {str(time.time() - start_time)}") - return png_data + return RenderResult( + html=html, + photo=png_data, + file_type=file_type, + cache=self.html_to_file_id_cache, + ttl=ttl, + caption=caption, + parse_mode=parse_mode, + filename=filename, + ) class TemplatePreviewer: @@ -128,7 +165,7 @@ class TemplatePreviewer: """注册预览用到的路由""" @webapp.get("/preview/{path:path}") - async def preview_template(path: str, key: Optional[str] = None): # pylint: disable=W0612 + async def preview_template(path: str, key: Optional[str] = None): # pylint: disable=W0612 # 如果是 /preview/ 开头的静态文件,直接返回内容。比如使用相对链接 ../ 引入的静态资源 if not path.endswith(".html"): full_path = self.template_service.template_dir / path diff --git a/plugins/genshin/abyss.py b/plugins/genshin/abyss.py index e40df39b..b2dc52c9 100644 --- a/plugins/genshin/abyss.py +++ b/plugins/genshin/abyss.py @@ -3,13 +3,13 @@ import asyncio import re from datetime import datetime from functools import lru_cache, partial -from typing import List, Match, Optional, Tuple +from typing import Any, Coroutine, List, Match, Optional, Tuple, Union import ujson as json from arkowrapper import ArkoWrapper from genshin import Client from pytz import timezone -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto, Update, Message +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, Message from telegram.constants import ChatAction, ParseMode from telegram.ext import CallbackContext, filters @@ -19,6 +19,7 @@ from core.cookies.error import CookiesNotFoundError, TooManyRequestPublicCookies from core.cookies.services import CookiesService from core.plugin import Plugin, handler from core.template import TemplateService +from core.template.models import RenderGroupResult, RenderResult from core.user import UserService from core.user.error import UserNotFoundError from metadata.genshin import game_id_to_role_id @@ -185,8 +186,10 @@ class Abyss(Plugin, BasePlugin): await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - for group in ArkoWrapper(images).map(InputMediaPhoto).group(10): # 每 10 张图片分一个组 - await message.reply_media_group(list(group), allow_sending_without_reply=True, write_timeout=60) + for group in ArkoWrapper(images).group(10): # 每 10 张图片分一个组 + await RenderGroupResult(results=group).reply_media_group( + message, allow_sending_without_reply=True, write_timeout=60 + ) if reply_text is not None: await reply_text.delete() @@ -195,7 +198,17 @@ class Abyss(Plugin, BasePlugin): async def get_rendered_pic( self, client: Client, uid: int, floor: int, total: bool, previous: bool - ) -> Optional[List[bytes]]: + ) -> Union[ + tuple[ + Union[BaseException, Any], + Union[BaseException, Any], + Union[BaseException, Any], + Union[BaseException, Any], + Union[BaseException, Any], + ], + list[RenderResult], + None, + ]: """ 获取渲染后的图片 @@ -258,50 +271,45 @@ class Abyss(Plugin, BasePlugin): data = json.loads(result) render_data["data"] = data - render_result = [] + render_inputs: List[Tuple[int, Coroutine[Any, Any, RenderResult]]] = [] - async def overview_task(): - render_result.append( - [ - -1, - await self.template_service.render( - "genshin/abyss/overview.html", render_data, viewport={"width": 750, "height": 580} - ), - ] + def overview_task(): + return -1, self.template_service.render( + "genshin/abyss/overview.html", render_data, viewport={"width": 750, "height": 580} ) - async def floor_task(floor_index: int): + def floor_task(floor_index: int): floor_d = data["floors"][floor_index] - render_result.append( - [ - floor_d["floor"], - await self.template_service.render( - "genshin/abyss/floor.html", - { - **render_data, - "floor": floor_d, - "total_stars": f"{floor_d['stars']}/{floor_d['max_stars']}", - }, - viewport={"width": 690, "height": 500}, - full_page=True, - ), - ] + return ( + floor_d["floor"], + self.template_service.render( + "genshin/abyss/floor.html", + { + **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, + ), ) - task_list = [asyncio.create_task(overview_task())] + render_inputs.append(overview_task()) + for i, f in enumerate(data["floors"]): if f["floor"] >= 9: - task_list.append(asyncio.create_task(floor_task(i))) - await asyncio.gather(*task_list) + 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) - return list(map(lambda x: x[1], sorted(render_result, key=lambda x: x[0]))) elif floor < 1: render_data["data"] = json.loads(result) return [ await self.template_service.render( - "genshin/abyss/overview.html", - render_data, - viewport={"width": 750, "height": 580}, + "genshin/abyss/overview.html", render_data, viewport={"width": 750, "height": 580} ) ] else: @@ -330,6 +338,6 @@ class Abyss(Plugin, BasePlugin): render_data["total_stars"] = f"{floor_data[0]['stars']}/{floor_data[0]['max_stars']}" return [ await self.template_service.render( - "genshin/abyss/floor.html", render_data, viewport={"width": 690, "height": 500}, full_page=True + "genshin/abyss/floor.html", render_data, viewport={"width": 690, "height": 500} ) ] diff --git a/plugins/genshin/abyss_team.py b/plugins/genshin/abyss_team.py index b1d2f4a3..fd40c545 100644 --- a/plugins/genshin/abyss_team.py +++ b/plugins/genshin/abyss_team.py @@ -82,11 +82,11 @@ class AbyssTeam(Plugin, BasePlugin): abyss_teams_data["teams"].append(team) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - png_data = await self.template_service.render( + render_result = await self.template_service.render( "genshin/abyss_team/abyss_team.html", abyss_teams_data, {"width": 785, "height": 800}, full_page=True, query_selector=".bg-contain", ) - await message.reply_photo(png_data, filename=f"abyss_team_{user.id}.png", allow_sending_without_reply=True) + await render_result.reply_photo(message, filename=f"abyss_team_{user.id}.png", allow_sending_without_reply=True) diff --git a/plugins/genshin/avatar_list.py b/plugins/genshin/avatar_list.py index 2a69b2ff..35bc706b 100644 --- a/plugins/genshin/avatar_list.py +++ b/plugins/genshin/avatar_list.py @@ -1,12 +1,12 @@ """练度统计""" import asyncio -from typing import Iterable, List, Optional, Sequence, Tuple +from typing import Iterable, List, Optional, Sequence from arkowrapper import ArkoWrapper from enkanetwork import Assets as EnkaAssets, EnkaNetworkAPI from genshin import Client, GenshinException from genshin.models import CalculatorCharacterDetails, CalculatorTalent, Character -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Message, Update, User +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update, User from telegram.constants import ChatAction, ParseMode from telegram.ext import CallbackContext, filters @@ -17,6 +17,7 @@ from core.cookies.error import CookiesNotFoundError from core.cookies.services import CookiesService from core.plugin import Plugin, handler from core.template import TemplateService +from core.template.models import FileType from core.user.error import UserNotFoundError from metadata.genshin import AVATAR_DATA, NAMECARD_DATA from modules.wiki.base import Model @@ -113,18 +114,14 @@ class AvatarListPlugin(Plugin, BasePlugin): async def get_avatars_data( self, characters: Sequence[Character], client: Client, max_length: int = None ) -> List["AvatarData"]: - task_result: List[Tuple[int, AvatarData]] = [] - async def _task(c, n): - if (result := await self.get_avatar_data(c, client)) is not None: - task_result.append((n, result)) + return n, await self.get_avatar_data(c, client) - task_list = [] - for num, character in enumerate(characters[:max_length]): - task_list.append(asyncio.create_task(_task(character, num))) + task_results = await asyncio.gather( + *[_task(character, num) for num, character in enumerate(characters[:max_length])] + ) - await asyncio.gather(*task_list) - return list(map(lambda x: x[1], sorted(task_result, key=lambda x: x[0]))) + return list(filter(lambda x: x, map(lambda x: x[1], sorted(task_results, key=lambda x: x[0])))) async def get_final_data(self, client: Client, characters: Sequence[Character], update: Update): try: @@ -218,7 +215,9 @@ class AvatarListPlugin(Plugin, BasePlugin): "has_more": len(characters) != len(avatar_datas), # 是否显示了全部角色 } - await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT if all_avatars else ChatAction.UPLOAD_PHOTO) + as_document = True if all_avatars and len(characters) > 20 else False + + await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT if as_document else ChatAction.UPLOAD_PHOTO) image = await self.template_service.render( "genshin/avatar_list/main.html", @@ -226,12 +225,14 @@ class AvatarListPlugin(Plugin, BasePlugin): viewport={"width": 1040, "height": 500}, full_page=True, query_selector=".container", + file_type=FileType.DOCUMENT if as_document else FileType.PHOTO, + ttl=30 * 24 * 60 * 60, ) self._add_delete_message_job(context, notice.chat_id, notice.message_id, 5) - if all_avatars and len(characters) > 20: - await message.reply_document(InputFile(image, filename="练度统计.png")) + if as_document: + await image.reply_document(message, filename="练度统计.png") else: - await message.reply_photo(image) + await image.reply_photo(message) logger.info( f"用户 {user.full_name}[{user.id}] [bold]练度统计[/bold]发送{'文件' if all_avatars else '图片'}成功", diff --git a/plugins/genshin/daily/material.py b/plugins/genshin/daily/material.py index e4426e76..228471ef 100644 --- a/plugins/genshin/daily/material.py +++ b/plugins/genshin/daily/material.py @@ -18,7 +18,7 @@ from bs4 import BeautifulSoup from genshin import Client from httpx import AsyncClient, HTTPError from pydantic import BaseModel -from telegram import InputMediaDocument, InputMediaPhoto, Message, Update, User +from telegram import Message, Update, User from telegram.constants import ChatAction, ParseMode from telegram.error import RetryAfter, TimedOut from telegram.ext import CallbackContext @@ -28,6 +28,7 @@ from core.baseplugin import BasePlugin from core.cookies.error import CookiesNotFoundError from core.plugin import Plugin, handler from core.template import TemplateService +from core.template.models import FileType, RenderGroupResult from core.user.error import UserNotFoundError from metadata.genshin import AVATAR_DATA, HONEY_DATA from utils.bot import get_all_args @@ -186,7 +187,7 @@ class DailyMaterial(Plugin, BasePlugin): title = "今日" weekday = now.weekday() - (1 if now.hour < 4 else 0) weekday = 6 if weekday < 0 else weekday - time = now.strftime("%m-%d %H:%M") + " 星期" + WEEK_MAP[weekday] + time = f"星期{WEEK_MAP[weekday]}" full = bool(args and args[-1] == "full") # 判定最后一个参数是不是 full logger.info(f'用户 {user.full_name}[{user.id}] 每日素材命令请求 || 参数 weekday="{WEEK_MAP[weekday]}" full={full}') @@ -275,35 +276,35 @@ class DailyMaterial(Plugin, BasePlugin): setattr(render_data, {"avatar": "character"}.get(type_, type_), areas) await message.reply_chat_action(ChatAction.TYPING) - render_tasks = [ - asyncio.create_task( - self.template_service.render( # 渲染角色素材页 - "genshin/daily_material/character.html", {"data": render_data}, {"width": 1164, "height": 500} - ) - ), - asyncio.create_task( - self.template_service.render( # 渲染武器素材页 - "genshin/daily_material/weapon.html", {"data": render_data}, {"width": 1164, "height": 500} - ) - ), - ] - results = await asyncio.gather(*render_tasks) + # 是否发送原图 + file_type = FileType.DOCUMENT if full else FileType.PHOTO - character_img_data = results[0] - weapon_img_data = results[1] + character_img_data, weapon_img_data = await asyncio.gather( + self.template_service.render( # 渲染角色素材页 + "genshin/daily_material/character.html", + {"data": render_data}, + {"width": 1164, "height": 500}, + file_type=file_type, + ttl=7 * 24 * 60 * 60, + ), + self.template_service.render( # 渲染武器素材页 + "genshin/daily_material/weapon.html", + {"data": render_data}, + {"width": 1164, "height": 500}, + file_type=file_type, + ttl=7 * 24 * 60 * 60, + ), + ) self._add_delete_message_job(context, notice.chat_id, notice.message_id, 5) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - if full: # 是否发送原图 - await message.reply_media_group( - [ - InputMediaDocument(character_img_data, filename="可培养角色.png"), - InputMediaDocument(weapon_img_data, filename="可培养武器.png"), - ] - ) - else: - await message.reply_media_group([InputMediaPhoto(character_img_data), InputMediaPhoto(weapon_img_data)]) + + 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", block=False) @@ -428,7 +429,7 @@ class DailyMaterial(Plugin, BasePlugin): for ID, DATA in ITEMS.items(): if (ITEM := [ID, DATA[1], TYPE]) not in new_items: new_items.append(ITEM) - task_list.append(asyncio.create_task(task(*ITEM))) + task_list.append(task(*ITEM)) await asyncio.gather(*task_list) # 等待所有任务执行完成 logger.info("图标素材下载完成") diff --git a/plugins/genshin/daily_note.py b/plugins/genshin/daily_note.py index 9058cab1..adcff691 100644 --- a/plugins/genshin/daily_note.py +++ b/plugins/genshin/daily_note.py @@ -11,7 +11,7 @@ from core.baseplugin import BasePlugin from core.cookies.error import CookiesNotFoundError from core.cookies.services import CookiesService from core.plugin import Plugin, handler -from core.template.services import TemplateService +from core.template.services import RenderResult, TemplateService from core.user.error import UserNotFoundError from core.user.services import UserService from utils.decorators.error import error_callable @@ -34,7 +34,7 @@ class DailyNote(Plugin, BasePlugin): self.user_service = user_service self.current_dir = os.getcwd() - async def _get_daily_note(self, client) -> bytes: + async def _get_daily_note(self, client) -> RenderResult: daily_info = await client.get_genshin_notes(client.uid) day = datetime.datetime.now().strftime("%m-%d %H:%M") + " 星期" + "一二三四五六日"[datetime.datetime.now().weekday()] resin_recovery_time = ( @@ -85,14 +85,14 @@ class DailyNote(Plugin, BasePlugin): "transformer_ready": transformer_ready, "transformer_recovery_time": transformer_recovery_time, } - png_data = await self.template_service.render( + render_result = await self.template_service.render( "genshin/daily_note/daily_note.html", daily_data, {"width": 600, "height": 548}, full_page=False ) - return png_data + return render_result @handler(CommandHandler, command="dailynote", block=False) @handler(MessageHandler, filters=filters.Regex("^当前状态(.*)"), block=False) - @restricts(return_data=ConversationHandler.END) + @restricts(30) @error_callable async def command_start(self, update: Update, context: CallbackContext) -> Optional[int]: user = update.effective_user @@ -100,7 +100,7 @@ class DailyNote(Plugin, BasePlugin): logger.info(f"用户 {user.full_name}[{user.id}] 查询游戏状态命令请求") try: client = await get_genshin_client(user.id) - png_data = await self._get_daily_note(client) + render_result = await self._get_daily_note(client) except (UserNotFoundError, CookiesNotFoundError): if filters.ChatType.GROUPS.filter(message): buttons = [[InlineKeyboardButton("点我私聊", url=f"https://t.me/{context.bot.username}?start=set_cookie")]] @@ -120,4 +120,4 @@ class DailyNote(Plugin, BasePlugin): self._add_delete_message_job(context, message.chat_id, message.message_id, 300) return ConversationHandler.END await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo(png_data, filename=f"{client.uid}.png", allow_sending_without_reply=True) + await render_result.reply_photo(message, filename=f"{client.uid}.png", allow_sending_without_reply=True) diff --git a/plugins/genshin/gacha/gacha_log.py b/plugins/genshin/gacha/gacha_log.py index cab7caa2..0e78c478 100644 --- a/plugins/genshin/gacha/gacha_log.py +++ b/plugins/genshin/gacha/gacha_log.py @@ -14,6 +14,7 @@ from core.cookies import CookiesService from core.cookies.error import CookiesNotFoundError from core.plugin import Plugin, handler, conversation from core.template import TemplateService +from core.template.models import FileType from core.user import UserService from core.user.error import UserNotFoundError from metadata.scripts.paimon_moe import update_paimon_moe_zh, GACHA_LOG_PAIMON_MOE_PATH @@ -29,7 +30,6 @@ from modules.gacha_log.error import ( from modules.gacha_log.helpers import from_url_get_authkey from modules.gacha_log.log import GachaLog from utils.bot import get_all_args -from utils.const import PROJECT_ROOT from utils.decorators.admins import bot_admins_rights_check from utils.decorators.error import error_callable from utils.decorators.restricts import restricts @@ -341,7 +341,7 @@ class GachaLogPlugin(Plugin.Conversation, BasePlugin.Conversation): png_data = await self.template_service.render( "genshin/gacha_log/gacha_log.html", data, full_page=True, query_selector=".body_box" ) - await message.reply_photo(png_data) + await png_data.reply_photo(message) except GachaLogNotFound: await message.reply_text("派蒙没有找到你的抽卡记录,快来私聊派蒙导入吧~") except GachaLogAccountNotFound: @@ -390,15 +390,18 @@ class GachaLogPlugin(Plugin.Conversation, BasePlugin.Conversation): if data["hasMore"] and not group: document = True data["hasMore"] = False + await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT if document else ChatAction.UPLOAD_PHOTO) png_data = await self.template_service.render( - "genshin/gacha_count/gacha_count.html", data, full_page=True, query_selector=".body_box" + "genshin/gacha_count/gacha_count.html", + data, + full_page=True, + query_selector=".body_box", + file_type=FileType.DOCUMENT if document else FileType.PHOTO, ) if document: - await message.reply_chat_action(ChatAction.UPLOAD_DOCUMENT) - await message.reply_document(png_data, filename="抽卡统计.png") + await png_data.reply_document(message, filename="抽卡统计.png") else: - await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo(png_data) + await png_data.reply_photo(message) except GachaLogNotFound: await message.reply_text("派蒙没有找到你的抽卡记录,快来私聊派蒙导入吧~") except GachaLogAccountNotFound: diff --git a/plugins/genshin/help.py b/plugins/genshin/help.py index d6011008..c20764e7 100644 --- a/plugins/genshin/help.py +++ b/plugins/genshin/help.py @@ -1,9 +1,7 @@ from telegram import Update from telegram.constants import ChatAction -from telegram.error import BadRequest from telegram.ext import CommandHandler, CallbackContext -from core.bot import bot from core.plugin import Plugin, handler from core.template import TemplateService from utils.decorators.error import error_callable @@ -13,8 +11,6 @@ from utils.log import logger class HelpPlugin(Plugin): def __init__(self, template_service: TemplateService = None): - self.file_id = None - self.help_png = None if template_service is None: raise ModuleNotFoundError self.template_service = template_service @@ -26,18 +22,9 @@ class HelpPlugin(Plugin): user = update.effective_user message = update.effective_message logger.info(f"用户 {user.full_name}[{user.id}] 发出help命令") - if self.file_id is None or bot.config.debug: - await message.reply_chat_action(ChatAction.TYPING) - help_png = await self.template_service.render("bot/help/help.html", {}, {"width": 1280, "height": 900}) - await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - reply_photo = await message.reply_photo(help_png, filename="help.png", allow_sending_without_reply=True) - photo = reply_photo.photo[0] - self.file_id = photo.file_id - else: - try: - await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo(self.file_id, allow_sending_without_reply=True) - except BadRequest as error: - self.file_id = None - logger.error("发送图片失败,尝试清空已经保存的file_id,错误信息为", error) - await message.reply_text("发送图片失败", allow_sending_without_reply=True) + await message.reply_chat_action(ChatAction.TYPING) + render_result = await self.template_service.render( + "bot/help/help.html", {}, {"width": 1280, "height": 900}, ttl=30 * 24 * 60 * 60 + ) + await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) + await render_result.reply_photo(message, filename="help.png", allow_sending_without_reply=True) diff --git a/plugins/genshin/ledger.py b/plugins/genshin/ledger.py index c05f82e4..102f5ca7 100644 --- a/plugins/genshin/ledger.py +++ b/plugins/genshin/ledger.py @@ -11,7 +11,7 @@ from core.baseplugin import BasePlugin from core.cookies.error import CookiesNotFoundError from core.cookies.services import CookiesService from core.plugin import Plugin, handler -from core.template.services import TemplateService +from core.template.services import RenderResult, TemplateService from core.user.error import UserNotFoundError from core.user.services import UserService from utils.bot import get_all_args @@ -65,7 +65,7 @@ class Ledger(Plugin, BasePlugin): self.user_service = user_service self.current_dir = os.getcwd() - async def _start_get_ledger(self, client, month=None) -> bytes: + async def _start_get_ledger(self, client, month=None) -> RenderResult: try: diary_info = await client.get_diary(client.uid, month=month) except GenshinException as error: @@ -98,10 +98,10 @@ class Ledger(Plugin, BasePlugin): "categories": categories, "color": color, } - png_data = await self.template_service.render( + render_result = await self.template_service.render( "genshin/ledger/ledger.html", ledger_data, {"width": 580, "height": 610} ) - return png_data + return render_result @handler(CommandHandler, command="ledger", block=False) @handler(MessageHandler, filters=filters.Regex("^旅行札记查询(.*)"), block=False) @@ -122,7 +122,7 @@ class Ledger(Plugin, BasePlugin): await message.reply_chat_action(ChatAction.TYPING) try: client = await get_genshin_client(user.id) - png_data = await self._start_get_ledger(client, month) + render_result = await self._start_get_ledger(client, month) except (UserNotFoundError, CookiesNotFoundError): if filters.ChatType.GROUPS.filter(message): buttons = [[InlineKeyboardButton("点我私聊", url=f"https://t.me/{context.bot.username}?start=set_cookie")]] @@ -142,4 +142,4 @@ class Ledger(Plugin, BasePlugin): self._add_delete_message_job(context, message.chat_id, message.message_id, 30) return await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo(png_data, filename=f"{client.uid}.png", allow_sending_without_reply=True) + await render_result.reply_photo(message, filename=f"{client.uid}.png", allow_sending_without_reply=True) diff --git a/plugins/genshin/player_cards.py b/plugins/genshin/player_cards.py index 03e49ef0..e0e8f848 100644 --- a/plugins/genshin/player_cards.py +++ b/plugins/genshin/player_cards.py @@ -17,7 +17,7 @@ from enkanetwork import ( VaildateUIDError, ) from pydantic import BaseModel -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto, Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatAction from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, MessageHandler, filters @@ -132,8 +132,8 @@ class PlayerCards(Plugin, BasePlugin): await message.reply_text(f"角色展柜中未找到 {character_name}") return await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - pnd_data = await RenderTemplate(uid, characters, self.template_service).render() # pylint: disable=W0631 - await message.reply_photo(pnd_data, filename=f"player_card_{uid}_{character_name}.png") + render_result = await RenderTemplate(uid, characters, self.template_service).render() # pylint: disable=W0631 + await render_result.reply_photo(message, filename=f"player_card_{uid}_{character_name}.png") @handler(CallbackQueryHandler, pattern=r"^get_player_card\|", block=False) @restricts(restricts_time_of_groups=20, without_overlapping=True) @@ -171,8 +171,9 @@ class PlayerCards(Plugin, BasePlugin): return await callback_query.answer(text="正在渲染图片中 请稍等 请不要重复点击按钮", show_alert=False) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - pnd_data = await RenderTemplate(uid, characters, self.template_service).render() # pylint: disable=W0631 - await message.edit_media(InputMediaPhoto(pnd_data, filename=f"player_card_{uid}_{result}.png")) + render_result = await RenderTemplate(uid, characters, self.template_service).render() # pylint: disable=W0631 + render_result.filename = f"player_card_{uid}_{result}.png" + await render_result.edit_media(message) class Artifact(BaseModel): @@ -195,15 +196,15 @@ class Artifact(BaseModel): 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), + ("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] @@ -243,15 +244,15 @@ class RenderTemplate: 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), + ("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] @@ -285,6 +286,7 @@ class RenderTemplate: {"width": 950, "height": 1080}, full_page=True, query_selector=".text-neutral-200", + ttl=7 * 24 * 60 * 60, ) async def de_stats(self) -> List[Tuple[str, Any]]: diff --git a/plugins/genshin/userstats.py b/plugins/genshin/userstats.py index d525b441..b430a532 100644 --- a/plugins/genshin/userstats.py +++ b/plugins/genshin/userstats.py @@ -15,6 +15,7 @@ from telegram.ext import ( from core.baseplugin import BasePlugin from core.cookies.error import CookiesNotFoundError, TooManyRequestPublicCookies from core.plugin import Plugin, handler +from core.template.models import RenderResult from core.template.services import TemplateService from core.user.error import UserNotFoundError from utils.decorators.error import error_callable @@ -51,7 +52,7 @@ class UserStatsPlugins(Plugin, BasePlugin): client = await get_genshin_client(user.id) except CookiesNotFoundError: client, uid = await get_public_genshin_client(user.id) - png_data = await self.render(client, uid) + render_result = await self.render(client, uid) except UserNotFoundError: if filters.ChatType.GROUPS.filter(message): buttons = [[InlineKeyboardButton("点我私聊", url=f"https://t.me/{context.bot.username}?start=set_uid")]] @@ -73,9 +74,9 @@ class UserStatsPlugins(Plugin, BasePlugin): await message.reply_text("角色数据有误 估计是派蒙晕了") return await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo(png_data, filename=f"{client.uid}.png", allow_sending_without_reply=True) + await render_result.reply_photo(message, filename=f"{client.uid}.png", allow_sending_without_reply=True) - async def render(self, client: Client, uid: Optional[int] = None) -> bytes: + async def render(self, client: Client, uid: Optional[int] = None) -> RenderResult: if uid is None: uid = client.uid diff --git a/plugins/genshin/weapon.py b/plugins/genshin/weapon.py index 7d8489de..ba5062ec 100644 --- a/plugins/genshin/weapon.py +++ b/plugins/genshin/weapon.py @@ -111,9 +111,11 @@ class WeaponPlugin(Plugin, BasePlugin): template_data = await input_template_data(weapon_data) png_data = await self.template_service.render( - "genshin/weapon/weapon.html", template_data, {"width": 540, "height": 540} + "genshin/weapon/weapon.html", template_data, {"width": 540, "height": 540}, ttl=31 * 24 * 60 * 60 ) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) - await message.reply_photo( - png_data, filename=f"{template_data['weapon_name']}.png", allow_sending_without_reply=True + await png_data.reply_photo( + message, + filename=f"{template_data['weapon_name']}.png", + allow_sending_without_reply=True, )