diff --git a/autopcr/module/modulelistmgr.py b/autopcr/module/modulelistmgr.py index cd524d7a..a5848bab 100644 --- a/autopcr/module/modulelistmgr.py +++ b/autopcr/module/modulelistmgr.py @@ -1,5 +1,5 @@ from typing import Dict, List, Callable, Any -from .modules import cron_modules, daily_modules, clan_modules, danger_modules, tool_modules, ModuleList, Module, CronModule, planning_modules, unit_modules, table_modules +from .modules import cron_modules, daily_modules, clan_modules, danger_modules, tool_modules, ModuleList, Module, CronModule, planning_modules, unit_modules, table_modules, notify_modules, NotifyModule from .modulemgr import ModuleManager class ModuleListManager: @@ -13,6 +13,7 @@ class ModuleListManager: table_modules.key: table_modules, clan_modules.key: clan_modules, danger_modules.key: danger_modules, + notify_modules.key: notify_modules, } name_to_modules: Dict[str, Callable] = {m.__name__: m for ml in modules.values() for m in ml.modules} @@ -27,6 +28,11 @@ def daily_modules(self) -> List[Module]: def cron_modules(self) -> List[CronModule]: return self.get_modules_list('cron') + @property + def notify_modules(self) -> List[NotifyModule]: + """获取通知模块列表""" + return self.get_modules_list('notify') + def get_module_from_key(self, key: str) -> Module: if key not in self.name_to_modules: raise ValueError(f"模块{key}未找到") diff --git a/autopcr/module/modulemgr.py b/autopcr/module/modulemgr.py index 302d4269..db7ba3c5 100644 --- a/autopcr/module/modulemgr.py +++ b/autopcr/module/modulemgr.py @@ -4,6 +4,8 @@ from typing import List, Dict from abc import abstractmethod, abstractproperty +from ..util.logger import instance as logger +from ..util.draw import instance as drawer from ..model.error import * from ..model.enums import * from ..db.database import db @@ -11,6 +13,7 @@ from ..core.clientpool import PoolClientWrapper import traceback import os +import base64 @dataclass_json @dataclass @@ -125,6 +128,7 @@ async def do_daily(self, isAdminCall: bool = False) -> "TaskResultInfo": if any(m.status == eResultStatus.PANIC or m.status == eResultStatus.ERROR for m in resp.result.values()): status = eResultStatus.ERROR res = await self.save_daily_result(resp, status) + await self.report_result(resp, status) return res async def do_from_key(self, config: dict, key: str, isAdminCall: bool = False) -> "ModuleResultInfo": @@ -137,45 +141,80 @@ async def do_from_key(self, config: dict, key: str, isAdminCall: bool = False) - res = await self.save_single_result(key, resp) return res - async def do_task(self, config: dict, modules: List[Module], isAdminCall: bool = False) -> TaskResult: - await db.enter_cache_scope() - try: - if db.is_clan_battle_time() and self.is_clan_battle_forbidden() and not isAdminCall: - key = 'clan_battle' if not modules else modules[0].key - return TaskResult( - order = [key], - result = { - key: ModuleResult( - status = eResultStatus.PANIC, - log = "会战期间禁止执行任务" - ) - } - ) - - client = self.client - activated = False - await client.activate() - activated = True - try: - self.config["stamina_relative_not_run"] = any(db.is_campaign(campaign) for campaign in self.config.get("stamina_relative_not_run_campaign_before_one_day", [])) - - self.config.update(config) - - resp: TaskResult = TaskResult( - order = [], - result = {} - ) - - for module in modules: - resp.order.append(module.key) - resp.result[module.key] = await module.do_from(client) - if resp.result[module.key].status == eResultStatus.PANIC: - break - - return resp - finally: - if activated: - client.deactivate() - finally: - await db.exit_cache_scope() - + async def do_task(self, config: dict, modules: List[Module], isAdminCall: bool = False) -> TaskResult: + await db.enter_cache_scope() + try: + if db.is_clan_battle_time() and self.is_clan_battle_forbidden() and not isAdminCall: + key = 'clan_battle' if not modules else modules[0].key + return TaskResult( + order = [key], + result = { + key: ModuleResult( + status = eResultStatus.PANIC, + log = "会战期间禁止执行任务" + ) + } + ) + + client = self.client + activated = False + await client.activate() + activated = True + try: + self.config["stamina_relative_not_run"] = any(db.is_campaign(campaign) for campaign in self.config.get("stamina_relative_not_run_campaign_before_one_day", [])) + + self.config.update(config) + + resp: TaskResult = TaskResult( + order = [], + result = {} + ) + + for module in modules: + resp.order.append(module.key) + resp.result[module.key] = await module.do_from(client) + if resp.result[module.key].status == eResultStatus.PANIC: + break + + return resp + finally: + if activated: + client.deactivate() + finally: + await db.exit_cache_scope() + + + async def report_result(self, resp: TaskResult, status: eResultStatus) -> None: + """任务执行完成后发送邮件报告""" + try: + # 使用 Drawer 绘制图片 + img = await drawer.draw_tasks_result(resp) + + # 转为 Base64 + img_byte_arr = await drawer.img2bytesio(img, format='PNG') + img_base64 = base64.b64encode(img_byte_arr.getvalue()).decode('utf-8') + + # 构建 HTML + html_body = f""" + + +

📊 任务执行报告

+

状态: {status.value}

+ + + + """ + + # 发送邮件 + notify_list = self.modules_list.notify_modules + for notify in notify_list: + + if notify.is_configured(): + await notify.send_notification( + subject=f"【AutoPCR】任务报告 - {status.value}", + body=html_body, + is_html=True + ) + + except Exception: + logger.exception("发送邮件报告失败: ") diff --git a/autopcr/module/modules/__init__.py b/autopcr/module/modules/__init__.py index e1c8347b..ee0a036b 100644 --- a/autopcr/module/modules/__init__.py +++ b/autopcr/module/modules/__init__.py @@ -23,6 +23,7 @@ from .unit import * from .talent import * from .mirage import * +from .notify import * @dataclass class ModuleList: @@ -228,3 +229,13 @@ class ModuleList: redeem_unit_swap, ] ) + +notify_modules = ModuleList( + '通知', + 'notify', + [ + email_notify + + ], + visible_in_clan=True, +) \ No newline at end of file diff --git a/autopcr/module/modules/notify.py b/autopcr/module/modules/notify.py new file mode 100644 index 00000000..f2857ce1 --- /dev/null +++ b/autopcr/module/modules/notify.py @@ -0,0 +1,179 @@ +# ============ module/notify.py ============ + +import smtplib +from email.header import Header +from email.mime.text import MIMEText + +from ...model.enums import * +from ...model.error import * +from ...util.logger import instance as logger +from ..config import * +from ..modulebase import * + +# ============ 配置定义 ============ + +EMAIL_PROVIDERS = ["QQ邮箱", "163邮箱", "126邮箱"] + +SMTP_CONFIGS = { + "QQ邮箱": {"host": "smtp.qq.com", "port": 587}, + "163邮箱": {"host": "smtp.163.com", "port": 465}, + "126邮箱": {"host": "smtp.126.com", "port": 465}, +} + + +# ============ 抽象基类 ============ + +class NotifyModule(Module): + """通知模块抽象基类""" + + def is_configured(self) -> bool: + raise NotImplementedError + + async def send_notification( + self, subject: str, body: str, is_html: bool = False + ) -> bool: + raise NotImplementedError + + +# ============ SMTP 实现 ============ + +class SmtpNotify(NotifyModule): + """SMTP 邮件通知实现(每次新建实例,但复用连接)""" + + # 🔥 类变量共享 SMTP 连接(所有实例复用同一个连接) + _server = None + + def __init__(self, modulemgr=None): + super().__init__(modulemgr) + + def get_notify_email_enable(self) -> bool: + return self.get_config("notify_email_enable") + + def get_notify_email_user(self) -> str: + return self.get_config("notify_email_user") + + def get_notify_email_to(self) -> str: + return self.get_config("notify_email_to") + + def get_notify_email_password(self) -> str: + return self.get_config("notify_email_password") + + def get_notify_email_provider(self) -> str: + return self.get_config("notify_email_provider") + + def get_smtp_config(self) -> dict: + provider = self.get_notify_email_provider() + return SMTP_CONFIGS.get(provider, SMTP_CONFIGS["QQ邮箱"]) + + def _get_server(self): + """获取或创建 SMTP 连接(类级别复用)""" + if SmtpNotify._server is not None: + return SmtpNotify._server + + smtp = self.get_smtp_config() + try: + if smtp.get("port") == 465: + server = smtplib.SMTP_SSL(smtp["host"], smtp["port"]) + else: + server = smtplib.SMTP(smtp["host"], smtp["port"]) + server.starttls() + + user = self.get_notify_email_user() + password = self.get_notify_email_password() + server.login(user, password) + + SmtpNotify._server = server + return server + + except smtplib.SMTPException as e: + logger.error(f"SMTP 连接失败: {e}") + return None + except Exception as e: + logger.exception("连接失败: ") + return None + + def _close_server(self): + """关闭 SMTP 连接""" + if SmtpNotify._server is not None: + try: + SmtpNotify._server.quit() + except Exception as e: + logger.exception("关闭 SMTP 连接时出错:") + SmtpNotify._server = None + + def is_configured(self) -> bool: + enable = self.get_notify_email_enable() + user = self.get_notify_email_user() + to = self.get_notify_email_to() + password = self.get_notify_email_password() + + if not enable: + return False + if not bool(user): + return False + if not bool(to): + return False + return bool(password) + + async def send_notification( + self, subject: str, body: str, is_html: bool = False + ) -> bool: + if not self.is_configured(): + logger.warning("邮件通知未配置,跳过发送") + return False + + user = self.get_notify_email_user() + to = self.get_notify_email_to() + + content_type = "html" if is_html else "plain" + msg = MIMEText(body, content_type, "utf-8") + msg["From"] = Header(user) + msg["To"] = Header(to) + msg["Subject"] = Header(subject, "utf-8") + + try: + server = self._get_server() + if server is None: + return False + + server.sendmail(user, [to], msg.as_string()) + logger.info(f"邮件发送成功: {subject} -> {to}") + return True + + except smtplib.SMTPServerDisconnected: + logger.warning("SMTP 连接已断开,尝试重新连接...") + self._close_server() + server = self._get_server() + if server is None: + logger.error("重新连接 SMTP 失败") + return False + try: + server.sendmail(user, [to], msg.as_string()) + logger.info(f"邮件重发成功: {subject} -> {to}") + return True + except Exception as e: + logger.exception("重发邮件失败: ") + return False + + except smtplib.SMTPException as e: + logger.exception("SMTP 邮件发送失败: ") + self._close_server() + return False + except Exception: + logger.exception("邮件发送失败:") + return False + + +# ============ 配置类 ============ + +@texttype("notify_email_to", "接收通知的邮箱", "") +@texttype("notify_email_password", "邮箱授权码", "") +@texttype("notify_email_user", "发送通知的邮箱", "") +@singlechoice("notify_email_provider", "邮箱服务商", "QQ邮箱", EMAIL_PROVIDERS) +@booltype("notify_email_enable", "启用邮件通知", False) +@description("邮件通知配置 - 任务完成后发送邮件提醒") +@name("邮件通知") +@default(False) +@notrunnable +class email_notify(SmtpNotify): + """邮件通知模块""" \ No newline at end of file