Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion autopcr/module/modulelistmgr.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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}

Expand All @@ -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}未找到")
Expand Down
123 changes: 81 additions & 42 deletions autopcr/module/modulemgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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
from .modulebase import Module, ModuleResult, eResultStatus
from ..core.clientpool import PoolClientWrapper
import traceback
import os
import base64

@dataclass_json
@dataclass
Expand Down Expand Up @@ -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":
Expand All @@ -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"""
<html>
<body>
<h2>📊 任务执行报告</h2>
<p>状态: {status.value}</p>
<img src="data:image/png;base64,{img_base64}" style="max-width: 100%;">
</body>
</html>
"""

# 发送邮件
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("发送邮件报告失败: ")
11 changes: 11 additions & 0 deletions autopcr/module/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .unit import *
from .talent import *
from .mirage import *
from .notify import *

@dataclass
class ModuleList:
Expand Down Expand Up @@ -228,3 +229,13 @@ class ModuleList:
redeem_unit_swap,
]
)

notify_modules = ModuleList(
'通知',
'notify',
[
email_notify

],
visible_in_clan=True,
)
179 changes: 179 additions & 0 deletions autopcr/module/modules/notify.py
Original file line number Diff line number Diff line change
@@ -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", "发送通知的邮箱", "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

邮件的发送者,是由用户配置的吗,不应该是搭建者配置?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我自己是服务端就自己使用。我觉得应该用户配置通知用户自己把,搭建者配置的话,所有用户的执行结果都通知搭建者么?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不过发送通知的邮箱和授权码可以搭建者配置,被通知的邮箱可以使用用户自己的

@singlechoice("notify_email_provider", "邮箱服务商", "QQ邮箱", EMAIL_PROVIDERS)
@booltype("notify_email_enable", "启用邮件通知", False)
@description("邮件通知配置 - 任务完成后发送邮件提醒")
@name("邮件通知")
@default(False)
@notrunnable
class email_notify(SmtpNotify):
"""邮件通知模块"""