重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。 Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
5.5 KiB
Python
184 lines
5.5 KiB
Python
"""
|
||
飞书 / Lark Markdown 通知 + 任务结果汇总。
|
||
内置 lark-notice-api 与 mengya-mail-api,无需外部 subprocess。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Callable
|
||
|
||
if TYPE_CHECKING:
|
||
from runner import TaskContext
|
||
|
||
CRON_FEISHU_WEBHOOK_DEFAULT = (
|
||
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
|
||
)
|
||
|
||
_VENDOR_ROOT = Path(__file__).resolve().parent / "vendor"
|
||
_LARK_SRC = _VENDOR_ROOT / "lark-notice-api" / "src"
|
||
_MAIL_SRC = _VENDOR_ROOT / "mengya-mail-api" / "src"
|
||
_MAIL_ENV_DEFAULT = _VENDOR_ROOT / "mengya-mail-api" / ".env"
|
||
|
||
|
||
def _ensure_vendor_imports() -> None:
|
||
for src in (_LARK_SRC, _MAIL_SRC):
|
||
text = str(src)
|
||
if src.is_dir() and text not in sys.path:
|
||
sys.path.insert(0, text)
|
||
|
||
|
||
@dataclass
|
||
class TaskResult:
|
||
"""任务执行结果,汇总 ok / skip / fail 三类条目。"""
|
||
|
||
ok: list[str] = field(default_factory=list)
|
||
skip: list[str] = field(default_factory=list)
|
||
fail: list[str] = field(default_factory=list)
|
||
|
||
def add_ok(self, item: str) -> None:
|
||
self.ok.append(item)
|
||
|
||
def add_skip(self, item: str) -> None:
|
||
self.skip.append(item)
|
||
|
||
def add_fail(self, item: str) -> None:
|
||
self.fail.append(item)
|
||
|
||
@property
|
||
def success(self) -> bool:
|
||
return not self.fail
|
||
|
||
@property
|
||
def status_cn(self) -> str:
|
||
return "成功" if self.success else "失败"
|
||
|
||
@property
|
||
def total(self) -> int:
|
||
return len(self.ok) + len(self.skip) + len(self.fail)
|
||
|
||
|
||
def markdown_list(items: list[str]) -> str:
|
||
"""将多行条目格式化为 Markdown 列表。"""
|
||
if not items:
|
||
return "- 无"
|
||
return "\n".join(f"- {x}" for x in items if x)
|
||
|
||
|
||
def markdown_fields(items: list[tuple[str, object]]) -> str:
|
||
"""将键值对格式化为更简洁的 Markdown 列表。"""
|
||
if not items:
|
||
return "- 无"
|
||
lines: list[str] = []
|
||
for key, value in items:
|
||
text = "-" if value is None or value == "" else str(value)
|
||
lines.append(f"- **{key}**:{text}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def send_task_summary(
|
||
ctx: "TaskContext",
|
||
result: TaskResult,
|
||
start_time: str,
|
||
*,
|
||
log: Callable[[str], None],
|
||
extra_fields: list[tuple[str, object]] | None = None,
|
||
) -> None:
|
||
"""发送标准任务汇总通知(飞书 + 邮件降级)。"""
|
||
from runner import task_output_prefix
|
||
|
||
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
|
||
|
||
fields: list[tuple[str, object]] = [
|
||
("任务", ctx.task_id),
|
||
("开始", start_time),
|
||
("结束", end_time),
|
||
]
|
||
if extra_fields:
|
||
fields.extend(extra_fields)
|
||
fields.extend([
|
||
("总数", result.total),
|
||
("成功", len(result.ok)),
|
||
("跳过", len(result.skip)),
|
||
("失败", len(result.fail)),
|
||
])
|
||
|
||
md = "\n".join([
|
||
markdown_fields(fields),
|
||
"", "**成功列表**", markdown_list(result.ok),
|
||
"", "**跳过列表**", markdown_list(result.skip),
|
||
"", "**失败列表**", markdown_list(result.fail),
|
||
])
|
||
send_feishu_markdown(subject, md, log=log)
|
||
|
||
|
||
def send_feishu_markdown(
|
||
title: str,
|
||
markdown: str,
|
||
*,
|
||
log: Callable[[str], None],
|
||
) -> None:
|
||
"""发送飞书 Markdown(失败时自动降级为邮件通知)。"""
|
||
if not title or not markdown:
|
||
log("飞书通知内容为空,跳过。")
|
||
return
|
||
|
||
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
|
||
if not webhook:
|
||
log("未配置飞书 webhook,跳过飞书通知。")
|
||
return
|
||
|
||
_ensure_vendor_imports()
|
||
from lark_notice_api.client import LarkNoticeError, LarkWebhookClient
|
||
|
||
try:
|
||
LarkWebhookClient(webhook).send_markdown(title, markdown)
|
||
return
|
||
except LarkNoticeError as exc:
|
||
log(f"发送飞书通知失败:{exc}")
|
||
except ImportError:
|
||
log("发送飞书通知失败:内置 lark-notice-api 不可用。")
|
||
|
||
log("飞书通知失败,尝试通过邮件发送…")
|
||
_send_mail_fallback(title, markdown, log)
|
||
|
||
|
||
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
|
||
"""通过内置 mengya-mail-api 发送邮件(失败仅记日志)。"""
|
||
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
|
||
if enabled != "1":
|
||
log("邮件通知未开启(export CRON_MAIL_ENABLED=1 可启用),跳过。")
|
||
return
|
||
|
||
env_file = os.environ.get("MAIL_API_ENV_FILE", str(_MAIL_ENV_DEFAULT))
|
||
if env_file and os.path.isfile(env_file):
|
||
os.environ.setdefault("MENGYA_MAIL_ENV_FILE", env_file)
|
||
|
||
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
|
||
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
|
||
|
||
_ensure_vendor_imports()
|
||
from mengya_mail_api.config import ConfigError, MailConfig
|
||
from mengya_mail_api.email_client import MailClient, MailClientError
|
||
|
||
try:
|
||
config = MailConfig.from_env()
|
||
MailClient(config).send_email(
|
||
to=mail_to,
|
||
subject=title,
|
||
html_body=markdown,
|
||
from_name=from_name,
|
||
)
|
||
log("邮件通知发送成功。")
|
||
except ConfigError as exc:
|
||
log(f"邮件配置错误:{exc}")
|
||
except MailClientError as exc:
|
||
log(f"邮件通知发送失败:{exc}")
|
||
except ImportError:
|
||
log("邮件通知发送失败:内置 mengya-mail-api 不可用。")
|