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