- 每任务一目录结构,统一开关与日志 - cronctl CLI:enable/disable/toggle/status/run/sync-cron - 公共库 lib/shumengya_cron:runner/manager/notify/ssh - _template 示例任务(hello world + 复制模板) - webui 管理面板:FastAPI 后端 + 文档式响应式前端
209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
"""
|
||
飞书 / Lark Markdown 通知 + 任务结果汇总。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, Callable
|
||
|
||
if TYPE_CHECKING:
|
||
from shumengya_cron.runner import TaskContext
|
||
|
||
CRON_FEISHU_WEBHOOK_DEFAULT = (
|
||
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
|
||
)
|
||
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
|
||
|
||
|
||
@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:
|
||
"""发送标准任务汇总通知(飞书 + 邮件降级)。
|
||
|
||
extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。
|
||
"""
|
||
from shumengya_cron.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)
|
||
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
|
||
if not webhook:
|
||
log("未配置飞书 webhook,跳过飞书通知。")
|
||
return
|
||
|
||
env = os.environ.copy()
|
||
pp = api_src
|
||
if env.get("PYTHONPATH"):
|
||
pp = f"{api_src}:{env['PYTHONPATH']}"
|
||
env["PYTHONPATH"] = pp
|
||
|
||
feishu_ok = False
|
||
try:
|
||
r = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"-m",
|
||
"lark_notice_api",
|
||
"--webhook",
|
||
webhook,
|
||
"send-markdown",
|
||
"--title",
|
||
title,
|
||
"--markdown",
|
||
markdown,
|
||
],
|
||
cwd=api_src if os.path.isdir(api_src) else None,
|
||
env=env,
|
||
check=False,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if r.returncode == 0:
|
||
feishu_ok = True
|
||
else:
|
||
detail = (r.stderr or r.stdout or "").strip()
|
||
if detail:
|
||
log(f"发送飞书通知失败(退出码 {r.returncode}):{detail}")
|
||
else:
|
||
log(f"发送飞书通知失败(退出码 {r.returncode})。")
|
||
except OSError:
|
||
log("发送飞书通知失败:lark-notice-api 调用异常。")
|
||
|
||
if feishu_ok:
|
||
return
|
||
|
||
log("飞书通知失败,尝试通过邮件发送…")
|
||
_send_mail_fallback(title, markdown, log)
|
||
|
||
|
||
# ── 邮件降级 ──────────────────────────────────────────────
|
||
|
||
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
|
||
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
|
||
|
||
|
||
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
|
||
|
||
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
|
||
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
|
||
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
|
||
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
|
||
|
||
if not os.path.isfile(script):
|
||
log(f"邮件脚本不存在:{script},跳过邮件通知。")
|
||
return
|
||
|
||
try:
|
||
r = subprocess.run(
|
||
[sys.executable, script, "--env-file", env_file, "--format", "json",
|
||
"send-email", "--to", mail_to, "--subject", title,
|
||
"--from-name", from_name, "--html-body", markdown],
|
||
capture_output=True, text=True, check=False,
|
||
)
|
||
if r.returncode == 0:
|
||
log("邮件通知发送成功。")
|
||
else:
|
||
detail = (r.stderr or r.stdout or "").strip()
|
||
log(f"邮件通知发送失败(退出码 {r.returncode}):{detail or '无详细信息'}")
|
||
except OSError as exc:
|
||
log(f"邮件通知发送异常:{exc}")
|