From 1051b0b48645d4422e98bcc686ab2bd163443a2c Mon Sep 17 00:00:00 2001 From: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:23:17 +0800 Subject: [PATCH] :sparkles: Support code post to channel --- .../apihelper/client/components/hyperion.py | 44 ++- modules/apihelper/models/genshin/hyperion.py | 33 +- plugins/admin/post_code.py | 335 ++++++++++++++++++ 3 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 plugins/admin/post_code.py diff --git a/modules/apihelper/client/components/hyperion.py b/modules/apihelper/client/components/hyperion.py index 33a80052..4fc3d503 100644 --- a/modules/apihelper/client/components/hyperion.py +++ b/modules/apihelper/client/components/hyperion.py @@ -1,10 +1,11 @@ import asyncio import os import re +from time import time from typing import List from ..base.hyperionrequest import HyperionRequest -from ...models.genshin.hyperion import PostInfo, ArtworkImage +from ...models.genshin.hyperion import PostInfo, ArtworkImage, LiveInfo, LiveCode, LiveCodeHoYo from ...typedefs import JSON_DATA __all__ = ("Hyperion",) @@ -20,6 +21,9 @@ class Hyperion: POST_FULL_IN_COLLECTION_URL = "https://bbs-api.miyoushe.com/post/wapi/getPostFullInCollection" GET_NEW_LIST_URL = "https://bbs-api.miyoushe.com/post/wapi/getNewsList" GET_OFFICIAL_RECOMMENDED_POSTS_URL = "https://bbs-api.miyoushe.com/post/wapi/getOfficialRecommendedPosts" + LIVE_INFO_URL = "https://api-takumi.mihoyo.com/event/miyolive/index" + LIVE_CODE_URL = "https://api-takumi-static.mihoyo.com/event/miyolive/refreshCode" + LIVE_CODE_HOYO_URL = "https://bbs-api-os.hoyolab.com/community/painter/wapi/circle/channel/guide/material" USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " @@ -139,5 +143,43 @@ class Hyperion: response = await self.client.get(url=self.GET_NEW_LIST_URL, params=params) return response + async def get_live_info(self, act_id: str) -> LiveInfo: + headers = {"x-rpc-act_id": act_id} + response = await self.client.get(url=self.LIVE_INFO_URL, headers=headers) + return LiveInfo(**response["live"]) + + async def get_live_code(self, act_id: str, ver_code: str) -> List[LiveCode]: + headers = {"x-rpc-act_id": act_id} + params = { + "version": ver_code, + "time": str(int(time())), + } + response = await self.client.get(url=self.LIVE_CODE_URL, headers=headers, params=params) + codes = [] + for code_data in response.get("code_list", []): + codes.append(LiveCode(**code_data)) + return codes + + async def get_live_code_hoyo(self, gid: int) -> List[LiveCodeHoYo]: + headers = self.get_headers("https://www.hoyolab.com/") + headers.update( + { + "x-rpc-app_version": "2.50.0", + "x-rpc-client_type": "4", + "x-rpc-language": "zh-cn", + } + ) + params = { + "game_id": str(gid), + } + codes = [] + response = await self.client.get(url=self.LIVE_CODE_HOYO_URL, headers=headers, params=params) + for module in response.get("modules", []): + if exchange_group := module.get("exchange_group"): + for code_data in exchange_group.get("bonuses", []): + codes.append(LiveCodeHoYo(**code_data)) + break + return codes + async def close(self): await self.client.shutdown() diff --git a/modules/apihelper/models/genshin/hyperion.py b/modules/apihelper/models/genshin/hyperion.py index 93296a2c..eefa4875 100644 --- a/modules/apihelper/models/genshin/hyperion.py +++ b/modules/apihelper/models/genshin/hyperion.py @@ -1,10 +1,11 @@ +from datetime import datetime from io import BytesIO from typing import Any, List, Optional from PIL import Image, UnidentifiedImageError from pydantic import BaseModel, PrivateAttr -__all__ = ("ArtworkImage", "PostInfo") +__all__ = ("ArtworkImage", "PostInfo", "LiveInfo", "LiveCode", "LiveCodeHoYo") class ArtworkImage(BaseModel): @@ -90,3 +91,33 @@ class PostInfo(BaseModel): def __getitem__(self, item): return self._data[item] + + +class LiveInfo(BaseModel): + act_type: str + title: str + live_time: str + start: datetime + end: datetime + remain: int + now: datetime + is_end: bool + code_ver: str + + +class LiveCode(BaseModel): + code: str + to_get_time: datetime + + @property + def text(self) -> str: + return self.code if self.code else "XXXXXXXXXXXX" + + +class LiveCodeHoYo(BaseModel): + exchange_code: str + offline_at: datetime + + @property + def text(self) -> str: + return self.exchange_code if self.exchange_code else "XXXXXXXXXXXX" diff --git a/plugins/admin/post_code.py b/plugins/admin/post_code.py new file mode 100644 index 00000000..0ac553b6 --- /dev/null +++ b/plugins/admin/post_code.py @@ -0,0 +1,335 @@ +import json +import re +from datetime import datetime, timezone, timedelta +from typing import List, Optional, Tuple, TYPE_CHECKING, Dict + +from httpx import Timeout +from telegram import ( + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) +from telegram.constants import ParseMode +from telegram.error import BadRequest, Forbidden +from telegram.ext import ConversationHandler, filters + +from core.config import config +from core.plugin import Plugin, conversation, handler +from modules.apihelper.client.components.hyperion import Hyperion +from modules.apihelper.error import APIHelperException +from utils.log import logger + +if TYPE_CHECKING: + from telegram import Update, Message + from telegram.ext import ContextTypes, Job + from modules.apihelper.models.genshin.hyperion import LiveCode, LiveCodeHoYo + + +class PostCodeHandlerData: + def __init__(self): + self.version: str = "" + self.act_id = "" + self.ver_code = "" + self.mys_code: List["LiveCode"] = [] + self.hoyo_code: List["LiveCodeHoYo"] = [] + self.channel_username: str = "" + self.channel_id: int = -1 + self.channel_msg: Optional["Message"] = None + self.need_update: bool = False + self.end_task_time: datetime = self.utc_8(datetime.now() + timedelta(hours=1)) + + def need_end_task(self) -> bool: + if not self.real_need_update(): + return True + now = self.utc_8(datetime.now()) + out_of_time = now >= self.end_task_time + if out_of_time: + logger.warning("PostCode 定时任务超过最大允许时间,结束任务") + return out_of_time + + @staticmethod + def utc_8(time: datetime) -> datetime: + return time.astimezone(timezone(timedelta(hours=8))) + + def get_end_time(self) -> str: + for code in self.hoyo_code: + if code.offline_at: + utc_8 = self.utc_8(code.offline_at) + return utc_8.strftime("%Y-%m-%d %H:%M:%S") + return "未知时间" + + def get_guess_last_time(self) -> datetime: + return self.utc_8(self.mys_code[-1].to_get_time) + + def get_need_update_text(self): + time = [] + for code in self.mys_code: + time.append(self.utc_8(code.to_get_time).strftime("%H:%M:%S")) + return UPDATE_TEMPLATE % tuple(time) + + def real_need_update(self) -> bool: + return not all([i.code for i in self.mys_code] + [i.exchange_code for i in self.hoyo_code]) + + def get_code_text(self) -> List[str]: + return [code.text for code in self.mys_code] + [code.text for code in self.hoyo_code] + + def have_changes(self, mys_code: List["LiveCode"], hoyo_code: List["LiveCodeHoYo"]) -> bool: + if len(mys_code) != len(self.mys_code) or len(hoyo_code) != len(self.hoyo_code): + return True + for i, code in enumerate(mys_code): + if code.code != self.mys_code[i].code: + return True + for i, code in enumerate(hoyo_code): + if code.exchange_code != self.hoyo_code[i].exchange_code: + return True + return False + + def get_text(self) -> str: + return POST_TEMPLATE % ( + self.version, + *self.get_code_text(), + self.get_end_time(), + ) + + +SEND_POST, CHECK_COMMAND, GET_POST_CHANNEL = range(10900, 10903) +POST_TEMPLATE = """《原神》%s 版本前瞻特别节目兑换码 + +国服: +%s - 原石 ×100,精锻用魔矿 x10 +%s - 原石 ×100,大英雄的经验 x5 +%s - 原石 ×100,摩拉 x50000 + +国际服: +%s - 原石 ×100,精锻用魔矿 x10 +%s - 原石 ×100,大英雄的经验 x5 +%s - 原石 ×100,摩拉 x50000 + +兑换码过期时间 %s UTC+8,请尽快领取。""" +UPDATE_TEMPLATE = """可能的兑换码发放时间: + +%s、%s、%s + +更新可能延迟三到五分钟,请耐心等待。""" + + +class PostCode(Plugin.Conversation): + """版本前瞻特别节目兑换码推送""" + + MENU_KEYBOARD = ReplyKeyboardMarkup([["推送频道", "推送并且定时更新"], ["退出"]], True, True) + SUBJECT_RE = re.compile(r"一起来看《原神》(\d+\.\d+)版本前瞻特别节目吧!") + ACT_RE = re.compile(r"act_id=(.*?)&") + + def __init__(self): + self.gids = 2 + self.type_id = 3 + + @staticmethod + def get_bbs_client() -> Hyperion: + return Hyperion( + timeout=Timeout( + connect=config.connect_timeout, + read=config.read_timeout, + write=config.write_timeout, + pool=config.pool_timeout, + ), + ) + + def init_version(self, news: List[Dict]) -> Tuple[Optional[str], Optional[Dict]]: + for new in news: + post = new.get("post", {}) + if not post: + continue + if not (subject := post.get("subject")): + continue + if not (match := self.SUBJECT_RE.match(subject)): + continue + return match.group(1), post + return None, None + + def init_act_id(self, post: Dict) -> Optional[str]: + structured_content = post.get("structured_content") + if not structured_content: + return None + structured_data = json.loads(structured_content) + for item in structured_data: + if not (attributes := item.get("attributes")): + continue + if not (link := attributes.get("link")): + continue + if not (match := self.ACT_RE.search(link)): + continue + return match.group(1) + return None + + async def init(self, post_code_handler_data: PostCodeHandlerData) -> bool: + """解析 act_id ver_code 以及目标游戏版本""" + client = self.get_bbs_client() + try: + news = await client.get_new_list(self.gids, self.type_id) + version, final_post = self.init_version(news.get("list", [])) + if not final_post: + raise ValueError("未找到版本前瞻特别节目文章") + act_id = self.init_act_id(final_post) + if not act_id: + raise ValueError("未找到文章中的 act_id") + live_info = await client.get_live_info(act_id) + ver_code = live_info.code_ver + post_code_handler_data.version = version + post_code_handler_data.act_id = act_id + post_code_handler_data.ver_code = ver_code + post_code_handler_data.mys_code = await client.get_live_code(act_id, ver_code) + post_code_handler_data.hoyo_code = await client.get_live_code_hoyo(self.gids) + if len(post_code_handler_data.mys_code) != 3 or len(post_code_handler_data.hoyo_code) != 3: + raise ValueError("获取兑换码数据成功,但是数量不对") + return True + finally: + await client.close() + + @conversation.entry_point + @handler.command(command="post_code", filters=filters.ChatType.PRIVATE, block=False, admin=True) + async def command_start(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int: + user = update.effective_user + message = update.effective_message + logger.info("用户 %s[%s] POST_CODE 命令请求", user.full_name, user.id) + post_code_handler_data = PostCodeHandlerData() + context.chat_data["post_code_handler_data"] = post_code_handler_data + text = f"✿✿ヽ(°▽°)ノ✿ 你好! {user.username} ,正在尝试自动获取必要的信息,请耐心等待。。。" + reply = await message.reply_text(text) + try: + result = await self.init(post_code_handler_data) + if not result: + await reply.edit_text("初始化基础信息失败,请检查是否有直播正在进行") + return ConversationHandler.END + await reply.delete() + await message.reply_text(post_code_handler_data.get_text(), parse_mode=ParseMode.HTML) + await message.reply_text("初始化信息完成,请选择你的操作", reply_markup=self.MENU_KEYBOARD) + return CHECK_COMMAND + except (APIHelperException, ValueError) as exc: + await reply.edit_text(f"初始化基础信息失败,错误信息:{str(exc)}") + return ConversationHandler.END + + @conversation.state(state=CHECK_COMMAND) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def check_command(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int: + post_code_handler_data: PostCodeHandlerData = context.chat_data.get("post_code_handler_data") + message = update.effective_message + if message.text == "退出": + await message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + if message.text == "推送频道": + return await self.get_channel(update, context) + if message.text == "推送并且定时更新": + if not post_code_handler_data.real_need_update(): + await message.reply_text("所有兑换码已发放,无需创建更新任务,将直接推送。", reply_markup=ReplyKeyboardRemove()) + return await self.get_channel(update, context) + post_code_handler_data.need_update = True + await message.reply_text(post_code_handler_data.get_need_update_text()) + return await self.get_channel(update, context) + return ConversationHandler.END + + async def get_channel(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> int: + message = update.effective_message + reply_keyboard = [] + try: + for channel_id in config.channels: + chat = await self.get_chat(chat_id=channel_id) + reply_keyboard.append([f"{chat.username}"]) + except KeyError as error: + logger.error("从配置文件获取频道信息发生错误,退出任务", exc_info=error) + await message.reply_text("从配置文件获取频道信息发生错误,退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await message.reply_text("请选择你要推送的频道", reply_markup=ReplyKeyboardMarkup(reply_keyboard, True, True)) + return GET_POST_CHANNEL + + @conversation.state(state=GET_POST_CHANNEL) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def get_post_channel(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int: + post_code_handler_data: PostCodeHandlerData = context.chat_data.get("post_code_handler_data") + message = update.effective_message + channel_id = -1 + try: + for channel_chat_id in config.channels: + chat = await self.get_chat(chat_id=channel_chat_id) + if message.text == chat.username: + channel_id = channel_chat_id + except KeyError as exc: + logger.error("从配置文件获取频道信息发生错误,退出任务", exc_info=exc) + logger.exception(exc) + await message.reply_text("从配置文件获取频道信息发生错误,退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + if channel_id == -1: + await message.reply_text("获取频道信息失败,请检查你输入的内容是否正确", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + post_code_handler_data.channel_username = message.text + post_code_handler_data.channel_id = channel_id + reply_keyboard = [["确认", "退出"]] + await message.reply_text("请核对你修改的信息", reply_markup=ReplyKeyboardMarkup(reply_keyboard, True, True)) + return SEND_POST + + @conversation.state(state=SEND_POST) + @handler.message(filters=filters.TEXT & ~filters.COMMAND, block=False) + async def send_post(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> int: + post_code_handler_data: PostCodeHandlerData = context.chat_data.get("post_code_handler_data") + message = update.effective_message + if message.text == "退出": + await message.reply_text(text="退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await message.reply_text("正在推送", reply_markup=ReplyKeyboardRemove()) + channel_id, channel_username = post_code_handler_data.channel_id, post_code_handler_data.channel_username + post_text = post_code_handler_data.get_text() + post_text += f"\n\n@{channel_username}" + try: + msg = await context.bot.send_message(channel_id, post_text, parse_mode=ParseMode.HTML) + if post_code_handler_data.need_update: + post_code_handler_data.channel_msg = msg + end_time = post_code_handler_data.get_guess_last_time() + timedelta(minutes=10) + post_code_handler_data.end_task_time = end_time + self.create_task(post_code_handler_data) + except BadRequest as exc: + await message.reply_text(f"发送消息时发生错误 {exc.message}", reply_markup=ReplyKeyboardRemove()) + logger.error("PostCode 模块发送消息时发生错误 %s", exc.message) + return ConversationHandler.END + await message.reply_text("推送成功", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + + def create_task(self, data: "PostCodeHandlerData"): + logger.debug("创建 PostCode 定时任务") + self.application.job_queue.run_once(self.post_code_task, 60, data=data) + + async def post_code_task(self, context: "ContextTypes.DEFAULT_TYPE"): + client = self.get_bbs_client() + job: "Job" = context.job + if not isinstance(job.data, PostCodeHandlerData): + return + post_code_handler_data: "PostCodeHandlerData" = job.data + if not post_code_handler_data.channel_msg: + return + act_id = post_code_handler_data.act_id + ver_code = post_code_handler_data.ver_code + channel_username = post_code_handler_data.channel_username + try: + mys_code = await client.get_live_code(act_id, ver_code) + hoyo_code = await client.get_live_code_hoyo(self.gids) + if len(post_code_handler_data.mys_code) != 3 or len(post_code_handler_data.hoyo_code) != 3: + raise ValueError("获取兑换码数据成功,但是数量不对") + if post_code_handler_data.have_changes(mys_code, hoyo_code): + post_code_handler_data.mys_code = mys_code + post_code_handler_data.hoyo_code = hoyo_code + post_text = post_code_handler_data.get_text() + post_text += f"\n\n@{channel_username}" + await post_code_handler_data.channel_msg.edit_text(post_text, parse_mode=ParseMode.HTML) + logger.success("PostCode 兑换码发生变化,已更新频道消息") + else: + logger.debug("PostCode 兑换码未发生变化") + except (APIHelperException, ValueError) as exc: + logger.error("获取兑换码时发生错误 %s", str(exc)) + except BadRequest as exc: + logger.error("自动更新兑换码消息失败 Message[%s]", exc.message) + except Forbidden as exc: + logger.error("自动更新兑换码消息失败 message[%s]", exc.message) + except Exception as exc: + logger.error("自动更新兑换码消息失败", exc_info=exc) + if post_code_handler_data.need_end_task(): + logger.success("PostCode 定时任务结束") + else: + self.create_task(post_code_handler_data)