feat: 多语言任务、WebUI 增强与 Agent MCP 集成
重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
lib/__init__.py
Normal file
5
lib/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""shumengya-cron:共用库(日志/锁、多语言调度、飞书通知、SSH 辅助)。"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
128
lib/cron_platform.py
Normal file
128
lib/cron_platform.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""跨平台辅助:文件锁、Windows 任务计划程序同步。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def lock_ex_nb(fd: int) -> None:
|
||||
"""非阻塞独占锁;无法获取时抛出 BlockingIOError。"""
|
||||
if is_windows():
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
except OSError as exc:
|
||||
raise BlockingIOError from exc
|
||||
return
|
||||
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
|
||||
def unlock(fd: int) -> None:
|
||||
if is_windows():
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def windows_task_name(task_id: str) -> str:
|
||||
return f"sproutclaw-{task_id}"
|
||||
|
||||
|
||||
def _cron_to_daily_time(expression: str) -> str | None:
|
||||
"""将 `分 时 * * *` 转为 schtasks 的 HH:MM;不支持则返回 None。"""
|
||||
parts = expression.split()
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
minute, hour, dom, month, dow = parts
|
||||
if dom != "*" or month != "*" or dow != "*":
|
||||
return None
|
||||
if not minute.isdigit() or not hour.isdigit():
|
||||
return None
|
||||
return f"{int(hour):02d}:{int(minute):02d}"
|
||||
|
||||
|
||||
def sync_windows_task(
|
||||
task_id: str,
|
||||
cron_root: Path,
|
||||
schedule_expression: str | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> str | None:
|
||||
"""在 Windows 任务计划程序中注册/更新定时任务。"""
|
||||
if schedule_expression is None:
|
||||
return "未找到 cron 表达式,跳过任务计划注册"
|
||||
|
||||
daily_time = _cron_to_daily_time(schedule_expression)
|
||||
if daily_time is None:
|
||||
return (
|
||||
f"暂不支持 cron 表达式「{schedule_expression}」自动转换,"
|
||||
"请手动创建任务计划或使用 `分 时 * * *` 形式"
|
||||
)
|
||||
|
||||
python_exe = Path(sys.executable).resolve()
|
||||
cronctl = (cron_root / "cronctl.py").resolve()
|
||||
task_name = windows_task_name(task_id)
|
||||
run_cmd = f'"{python_exe}" "{cronctl}" run {task_id}'
|
||||
|
||||
query = subprocess.run(
|
||||
["schtasks", "/Query", "/TN", task_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
exists = query.returncode == 0
|
||||
|
||||
if exists:
|
||||
subprocess.run(
|
||||
["schtasks", "/Delete", "/TN", task_name, "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
create = subprocess.run(
|
||||
[
|
||||
"schtasks",
|
||||
"/Create",
|
||||
"/TN",
|
||||
task_name,
|
||||
"/TR",
|
||||
run_cmd,
|
||||
"/SC",
|
||||
"DAILY",
|
||||
"/ST",
|
||||
daily_time,
|
||||
"/F",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if create.returncode != 0:
|
||||
err = (create.stderr or create.stdout or "").strip()
|
||||
return f"任务计划注册失败: {err or '未知错误'}"
|
||||
|
||||
state = "开启" if enabled else "关闭(计划仍触发,任务自动跳过)"
|
||||
action = "已更新" if exists else "已创建"
|
||||
return f"{action} Windows 任务「{task_name}」,每天 {daily_time}({state})"
|
||||
91
lib/external_runner.py
Normal file
91
lib/external_runner.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""非 Python 任务:由 Python 编排 disable / 锁 / 日志,subprocess 执行业务脚本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from runner import (
|
||||
TaskContext,
|
||||
acquire_cron_lock,
|
||||
append_raw_to_log,
|
||||
task_is_disabled,
|
||||
task_logging,
|
||||
)
|
||||
from task_manifest import TaskManifest
|
||||
|
||||
|
||||
def _resolve_interpreter(runtime: str) -> list[str] | None:
|
||||
if runtime == "javascript":
|
||||
node = shutil.which("node")
|
||||
return [node] if node else None
|
||||
if runtime == "bash":
|
||||
bash = shutil.which("bash")
|
||||
return [bash] if bash else None
|
||||
if runtime == "powershell":
|
||||
pwsh = shutil.which("pwsh")
|
||||
if pwsh:
|
||||
return [pwsh, "-File"]
|
||||
ps = shutil.which("powershell")
|
||||
return [ps, "-File"] if ps else None
|
||||
return None
|
||||
|
||||
|
||||
def build_command(manifest: TaskManifest, ctx: TaskContext) -> list[str]:
|
||||
interpreter = _resolve_interpreter(manifest.runtime)
|
||||
if interpreter is None:
|
||||
raise FileNotFoundError(f"未找到 {manifest.runtime} 解释器,请确认已安装并在 PATH 中")
|
||||
entry = ctx.task_dir / manifest.entry
|
||||
if manifest.runtime == "powershell":
|
||||
return [*interpreter, str(entry)]
|
||||
if manifest.runtime == "bash":
|
||||
return [*interpreter, str(entry)]
|
||||
return [*interpreter, str(entry)]
|
||||
|
||||
|
||||
def _task_env(ctx: TaskContext) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["CRON_TASK_ID"] = ctx.task_id
|
||||
env["CRON_TASK_DIR"] = str(ctx.task_dir)
|
||||
env["CRON_ROOT"] = str(ctx.cron_root)
|
||||
return env
|
||||
|
||||
|
||||
def run_external_task(ctx: TaskContext, manifest: TaskManifest) -> int:
|
||||
if task_is_disabled(ctx):
|
||||
return 0
|
||||
|
||||
with task_logging(ctx) as log:
|
||||
with acquire_cron_lock(ctx.lock_file, log=log) as locked:
|
||||
if not locked:
|
||||
return 0
|
||||
|
||||
log("start")
|
||||
log(f"runtime={manifest.runtime} entry={manifest.entry}")
|
||||
|
||||
try:
|
||||
cmd = build_command(manifest, ctx)
|
||||
except FileNotFoundError as exc:
|
||||
log(str(exc))
|
||||
log("end")
|
||||
return 1
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(ctx.task_dir),
|
||||
env=_task_env(ctx),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
||||
if output:
|
||||
append_raw_to_log(ctx.log_file, output)
|
||||
|
||||
if proc.returncode != 0:
|
||||
log(f"exit={proc.returncode}")
|
||||
|
||||
log("end")
|
||||
return proc.returncode
|
||||
@@ -2,30 +2,68 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from shumengya_cron.runner import (
|
||||
from external_runner import run_external_task
|
||||
from cron_platform import is_windows, lock_ex_nb, sync_windows_task, unlock
|
||||
from runner import (
|
||||
DISABLED_DIR_NAME,
|
||||
LogMode,
|
||||
TaskContext,
|
||||
set_task_enabled,
|
||||
task_is_enabled,
|
||||
)
|
||||
from task_manifest import (
|
||||
effective_task_tags,
|
||||
is_valid_task_dir,
|
||||
load_task_manifest,
|
||||
load_task_tags,
|
||||
normalize_tags,
|
||||
save_task_tags,
|
||||
)
|
||||
|
||||
CRON_D = Path("/etc/cron.d")
|
||||
_CRON_LINE = re.compile(
|
||||
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
|
||||
)
|
||||
_DESC_SKIP_PREFIX = ("开关",)
|
||||
|
||||
|
||||
def _is_description_comment(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
if text.startswith(_DESC_SKIP_PREFIX):
|
||||
return False
|
||||
if "cronctl" in text:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_cron_schedule(schedule: str) -> str:
|
||||
parts = schedule.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("cron 表达式须为 5 段:分 时 日 月 周")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _replace_cron_expression(line: str, new_schedule: str) -> str:
|
||||
stripped = line.strip()
|
||||
if not _CRON_LINE.match(stripped):
|
||||
return line
|
||||
parts = stripped.split(None, 6)
|
||||
if len(parts) < 7:
|
||||
raise ValueError("schedule.cron 中的 cron 行格式无法识别")
|
||||
return f"{new_schedule} {parts[5]} {parts[6]}"
|
||||
|
||||
|
||||
def cron_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
|
||||
@@ -55,13 +93,13 @@ def iter_task_ids(root: Path | None = None) -> list[str]:
|
||||
if path.name.endswith(".disabled"):
|
||||
task_ids.add(path.name.removesuffix(".disabled"))
|
||||
continue
|
||||
if (path / "run.py").is_file():
|
||||
if is_valid_task_dir(path):
|
||||
task_ids.add(path.name)
|
||||
|
||||
disabled_root = base / DISABLED_DIR_NAME
|
||||
if disabled_root.is_dir():
|
||||
for path in sorted(disabled_root.iterdir()):
|
||||
if path.is_dir() and (path / "run.py").is_file():
|
||||
if path.is_dir() and is_valid_task_dir(path):
|
||||
task_ids.add(path.name)
|
||||
|
||||
return sorted(task_ids)
|
||||
@@ -71,8 +109,8 @@ def get_context(task_id: str, root: Path | None = None) -> TaskContext:
|
||||
ctx = TaskContext.from_task_id(task_id, cron_root=root)
|
||||
if not ctx.task_dir.is_dir():
|
||||
raise FileNotFoundError(f"任务不存在: {task_id}")
|
||||
if not (ctx.task_dir / "run.py").is_file():
|
||||
raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}")
|
||||
if not is_valid_task_dir(ctx.task_dir):
|
||||
raise FileNotFoundError(f"任务目录缺少 schedule.cron 或入口脚本: {task_id}")
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -89,7 +127,7 @@ def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
|
||||
if line.startswith("#"):
|
||||
if description is None:
|
||||
text = line.lstrip("#").strip()
|
||||
if text and not text.startswith("开关") and "cronctl" not in text:
|
||||
if _is_description_comment(text):
|
||||
description = text
|
||||
continue
|
||||
match = _CRON_LINE.match(line)
|
||||
@@ -103,15 +141,16 @@ def task_is_running(ctx: TaskContext) -> bool:
|
||||
lock_file = ctx.lock_file
|
||||
if not lock_file.is_file():
|
||||
return False
|
||||
fd = lock_file.open("r")
|
||||
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
lock_ex_nb(fd)
|
||||
return False
|
||||
except BlockingIOError:
|
||||
return True
|
||||
finally:
|
||||
fd.close()
|
||||
unlock(fd)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -124,6 +163,8 @@ class TaskInfo:
|
||||
log_size: int
|
||||
log_updated: str | None
|
||||
task_dir: str
|
||||
runtime: str
|
||||
tags: list[str]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -138,6 +179,7 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
|
||||
stat = ctx.log_file.stat()
|
||||
log_size = stat.st_size
|
||||
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
manifest = load_task_manifest(ctx.task_dir)
|
||||
return TaskInfo(
|
||||
task_id=task_id,
|
||||
enabled=task_is_enabled(ctx),
|
||||
@@ -147,6 +189,8 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
|
||||
log_size=log_size,
|
||||
log_updated=log_updated,
|
||||
task_dir=str(ctx.task_dir),
|
||||
runtime=manifest.runtime,
|
||||
tags=effective_task_tags(ctx.task_dir, manifest),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,13 +200,24 @@ def list_tasks(root: Path | None = None) -> list[TaskInfo]:
|
||||
|
||||
|
||||
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
|
||||
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None。"""
|
||||
cron_d_file = CRON_D / ctx.task_id
|
||||
"""安装定时调度:Linux 写入 /etc/cron.d/,Windows 注册任务计划程序。"""
|
||||
schedule = ctx.task_dir / "schedule.cron"
|
||||
if not schedule.is_file():
|
||||
return "未找到 schedule.cron,跳过安装"
|
||||
|
||||
expression, _ = _parse_schedule(ctx.task_dir)
|
||||
|
||||
if is_windows():
|
||||
return sync_windows_task(
|
||||
ctx.task_id,
|
||||
ctx.cron_root,
|
||||
expression,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
cron_d_file = CRON_D / ctx.task_id
|
||||
shutil.copy2(schedule, cron_d_file)
|
||||
state = "开启" if enabled else "关闭(cron 仍触发,run.py 自动跳过)"
|
||||
state = "开启" if enabled else "关闭(cron 仍触发,任务自动跳过)"
|
||||
return f"已同步到 {cron_d_file}({state})"
|
||||
|
||||
|
||||
@@ -180,10 +235,14 @@ def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str |
|
||||
|
||||
|
||||
def run_task(task_id: str, root: Path | None = None) -> int:
|
||||
"""动态加载任务 run.py,调用其 run(ctx) 并返回退出码。"""
|
||||
"""执行任务:Python 动态加载 run.py,其余 runtime 由 external_runner 启动。"""
|
||||
ctx = get_context(task_id, root)
|
||||
run_py = ctx.task_dir / "run.py"
|
||||
manifest = load_task_manifest(ctx.task_dir)
|
||||
|
||||
if manifest.runtime != "python":
|
||||
return run_external_task(ctx, manifest)
|
||||
|
||||
run_py = ctx.task_dir / manifest.entry
|
||||
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
|
||||
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
@@ -202,3 +261,65 @@ def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> s
|
||||
if lines <= 0 or len(parts) <= lines:
|
||||
return content
|
||||
return "\n".join(parts[-lines:]) + "\n"
|
||||
|
||||
|
||||
def update_task_schedule(
|
||||
task_id: str,
|
||||
*,
|
||||
description: str,
|
||||
schedule: str,
|
||||
tags: list[str] | None = None,
|
||||
root: Path | None = None,
|
||||
sync: bool = True,
|
||||
) -> tuple[TaskInfo, str | None]:
|
||||
"""更新 schedule.cron 中的描述注释、cron 表达式前五段,以及 task.json 标签。"""
|
||||
ctx = get_context(task_id, root)
|
||||
schedule_file = ctx.task_dir / "schedule.cron"
|
||||
if not schedule_file.is_file():
|
||||
raise FileNotFoundError(f"未找到 schedule.cron: {task_id}")
|
||||
|
||||
new_schedule = _validate_cron_schedule(schedule)
|
||||
desc_text = description.strip()
|
||||
|
||||
raw_lines = schedule_file.read_text(encoding="utf-8").splitlines()
|
||||
desc_updated = False
|
||||
cron_updated = False
|
||||
new_lines: list[str] = []
|
||||
|
||||
for line in raw_lines:
|
||||
stripped = line.strip()
|
||||
if not desc_updated and stripped.startswith("#"):
|
||||
text = stripped.lstrip("#").strip()
|
||||
if _is_description_comment(text):
|
||||
new_lines.append(f"# {desc_text}" if desc_text else "#")
|
||||
desc_updated = True
|
||||
continue
|
||||
if _CRON_LINE.match(stripped):
|
||||
new_lines.append(_replace_cron_expression(line, new_schedule))
|
||||
cron_updated = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not cron_updated:
|
||||
raise ValueError("schedule.cron 中未找到 cron 调度行")
|
||||
|
||||
if not desc_updated and desc_text:
|
||||
inserted: list[str] = []
|
||||
for line in new_lines:
|
||||
if _CRON_LINE.match(line.strip()) and (not inserted or inserted[-1] != ""):
|
||||
inserted.append(f"# {desc_text}")
|
||||
inserted.append("")
|
||||
inserted.append(line)
|
||||
new_lines = inserted
|
||||
|
||||
schedule_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
|
||||
if tags is not None:
|
||||
save_task_tags(ctx.task_dir, normalize_tags(tags))
|
||||
|
||||
message = None
|
||||
if sync:
|
||||
ctx = get_context(task_id, root)
|
||||
message = sync_cron_d(ctx, task_is_enabled(ctx))
|
||||
|
||||
return build_task_info(task_id, root), message
|
||||
@@ -1,23 +1,35 @@
|
||||
"""
|
||||
飞书 / Lark Markdown 通知 + 任务结果汇总。
|
||||
内置 lark-notice-api 与 mengya-mail-api,无需外部 subprocess。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
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 shumengya_cron.runner import TaskContext
|
||||
from 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"
|
||||
|
||||
_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
|
||||
@@ -76,11 +88,8 @@ def send_task_summary(
|
||||
log: Callable[[str], None],
|
||||
extra_fields: list[tuple[str, object]] | None = None,
|
||||
) -> None:
|
||||
"""发送标准任务汇总通知(飞书 + 邮件降级)。
|
||||
|
||||
extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。
|
||||
"""
|
||||
from shumengya_cron.runner import task_output_prefix
|
||||
"""发送标准任务汇总通知(飞书 + 邮件降级)。"""
|
||||
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}"
|
||||
@@ -114,95 +123,61 @@ def send_feishu_markdown(
|
||||
*,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
"""发送飞书 Markdown(失败时自动降级为邮件通知,邮件也失败则结束)。"""
|
||||
"""发送飞书 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
|
||||
_ensure_vendor_imports()
|
||||
from lark_notice_api.client import LarkNoticeError, LarkWebhookClient
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
# ── 邮件降级 ──────────────────────────────────────────────
|
||||
|
||||
_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 发送报告邮件(失败仅记日志,不再继续降级)。"""
|
||||
"""通过内置 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)
|
||||
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")
|
||||
|
||||
if not os.path.isfile(script):
|
||||
log(f"邮件脚本不存在:{script},跳过邮件通知。")
|
||||
return
|
||||
_ensure_vendor_imports()
|
||||
from mengya_mail_api.config import ConfigError, MailConfig
|
||||
from mengya_mail_api.email_client import MailClient, MailClientError
|
||||
|
||||
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,
|
||||
config = MailConfig.from_env()
|
||||
MailClient(config).send_email(
|
||||
to=mail_to,
|
||||
subject=title,
|
||||
html_body=markdown,
|
||||
from_name=from_name,
|
||||
)
|
||||
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}")
|
||||
log("邮件通知发送成功。")
|
||||
except ConfigError as exc:
|
||||
log(f"邮件配置错误:{exc}")
|
||||
except MailClientError as exc:
|
||||
log(f"邮件通知发送失败:{exc}")
|
||||
except ImportError:
|
||||
log("邮件通知发送失败:内置 mengya-mail-api 不可用。")
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
@@ -17,6 +16,8 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, TextIO
|
||||
|
||||
from cron_platform import lock_ex_nb, unlock
|
||||
|
||||
|
||||
class LogMode(str, Enum):
|
||||
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
|
||||
@@ -26,8 +27,8 @@ class LogMode(str, Enum):
|
||||
|
||||
|
||||
def _cron_root() -> Path:
|
||||
# .../lib/shumengya_cron/runner.py -> cron 根目录
|
||||
return Path(__file__).resolve().parents[2]
|
||||
# .../lib/runner.py -> cron 根目录
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
DISABLED_DIR_NAME = ".disabled"
|
||||
@@ -184,7 +185,7 @@ def acquire_cron_lock(
|
||||
) -> Iterator[bool]:
|
||||
"""
|
||||
获取互斥锁;若已被占用则记录日志并 yield False(调用方应 sys.exit(0))。
|
||||
使用 flock(与 bash 版一致)。
|
||||
Linux 使用 flock;Windows 使用 msvcrt 文件锁。
|
||||
"""
|
||||
msg = busy_message or os.environ.get(
|
||||
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
|
||||
@@ -193,17 +194,14 @@ def acquire_cron_lock(
|
||||
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
lock_ex_nb(fd)
|
||||
except BlockingIOError:
|
||||
log(msg)
|
||||
yield False
|
||||
return
|
||||
yield True
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
unlock(fd)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
114
lib/runtime_probe.py
Normal file
114
lib/runtime_probe.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""检测本机 Python / Node.js / Bash / PowerShell 是否可用及版本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeProbe:
|
||||
id: str
|
||||
name: str
|
||||
available: bool
|
||||
version: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _first_line(text: str) -> str:
|
||||
line = (text or "").strip().splitlines()[0].strip() if text else ""
|
||||
return line
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, timeout: float = 8.0) -> tuple[int, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
out = _first_line((proc.stdout or "") + "\n" + (proc.stderr or ""))
|
||||
return proc.returncode, out
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 1, ""
|
||||
|
||||
|
||||
def _probe_python() -> RuntimeProbe:
|
||||
exe = sys.executable
|
||||
code, out = _run([exe, "--version"])
|
||||
version: str | None = None
|
||||
if code == 0 and out:
|
||||
version = out.removeprefix("Python ").strip() if out.lower().startswith("python") else out
|
||||
elif sys.version:
|
||||
version = sys.version.split()[0]
|
||||
return RuntimeProbe(
|
||||
id="python",
|
||||
name="Python",
|
||||
available=True,
|
||||
version=version,
|
||||
path=exe,
|
||||
)
|
||||
|
||||
|
||||
def _probe_nodejs() -> RuntimeProbe:
|
||||
path = shutil.which("node")
|
||||
if not path:
|
||||
return RuntimeProbe(id="nodejs", name="JavaScript", available=False)
|
||||
code, out = _run([path, "--version"])
|
||||
if code != 0 or not out:
|
||||
return RuntimeProbe(id="nodejs", name="JavaScript", available=False, path=path)
|
||||
version = out.lstrip("vV").strip() or out
|
||||
return RuntimeProbe(id="nodejs", name="JavaScript", available=True, version=version, path=path)
|
||||
|
||||
|
||||
def _parse_bash_version(text: str) -> str | None:
|
||||
match = re.search(r"version\s+([^\s(]+)", text, re.I)
|
||||
return match.group(1) if match else (text or None)
|
||||
|
||||
|
||||
def _probe_bash() -> RuntimeProbe:
|
||||
path = shutil.which("bash")
|
||||
if not path:
|
||||
return RuntimeProbe(id="bash", name="Bash", available=False)
|
||||
code, out = _run([path, "--version"])
|
||||
if code != 0 or not out:
|
||||
return RuntimeProbe(id="bash", name="Bash", available=False, path=path)
|
||||
version = _parse_bash_version(out)
|
||||
return RuntimeProbe(id="bash", name="Bash", available=True, version=version, path=path)
|
||||
|
||||
|
||||
def _probe_powershell() -> RuntimeProbe:
|
||||
for cmd_name in ("pwsh", "powershell"):
|
||||
path = shutil.which(cmd_name)
|
||||
if not path:
|
||||
continue
|
||||
code, out = _run(
|
||||
[path, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]
|
||||
)
|
||||
if code == 0 and out:
|
||||
return RuntimeProbe(
|
||||
id="powershell",
|
||||
name="PowerShell",
|
||||
available=True,
|
||||
version=out.strip(),
|
||||
path=path,
|
||||
)
|
||||
return RuntimeProbe(id="powershell", name="PowerShell", available=False)
|
||||
|
||||
|
||||
def probe_all_runtimes() -> list[dict[str, object]]:
|
||||
return [
|
||||
_probe_python().to_dict(),
|
||||
_probe_nodejs().to_dict(),
|
||||
_probe_bash().to_dict(),
|
||||
_probe_powershell().to_dict(),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""shumengya-cron:共用库(日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
165
lib/task_manifest.py
Normal file
165
lib/task_manifest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""任务清单 task.json 解析与 runtime 推断。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
Runtime = Literal["python", "javascript", "bash", "powershell"]
|
||||
|
||||
RUNTIME_DEFAULT_ENTRY: dict[str, str] = {
|
||||
"python": "run.py",
|
||||
"javascript": "run.js",
|
||||
"bash": "run.sh",
|
||||
"powershell": "run.ps1",
|
||||
}
|
||||
|
||||
RUNTIME_ALIASES: dict[str, Runtime] = {
|
||||
"python": "python",
|
||||
"py": "python",
|
||||
"javascript": "javascript",
|
||||
"js": "javascript",
|
||||
"node": "javascript",
|
||||
"bash": "bash",
|
||||
"sh": "bash",
|
||||
"shell": "bash",
|
||||
"powershell": "powershell",
|
||||
"ps1": "powershell",
|
||||
"pwsh": "powershell",
|
||||
}
|
||||
|
||||
ENTRY_TO_RUNTIME: dict[str, Runtime] = {
|
||||
"run.py": "python",
|
||||
"run.js": "javascript",
|
||||
"run.sh": "bash",
|
||||
"run.ps1": "powershell",
|
||||
}
|
||||
|
||||
RUNTIME_LABEL: dict[str, str] = {
|
||||
"python": "Python",
|
||||
"javascript": "JavaScript",
|
||||
"bash": "Bash",
|
||||
"powershell": "PowerShell",
|
||||
}
|
||||
|
||||
MAX_TASK_TAGS = 4
|
||||
MAX_TAG_LENGTH = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskManifest:
|
||||
runtime: Runtime
|
||||
entry: str
|
||||
|
||||
@property
|
||||
def entry_path(self) -> str:
|
||||
return self.entry
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return RUNTIME_LABEL.get(self.runtime, self.runtime)
|
||||
|
||||
|
||||
def _normalize_runtime(raw: str) -> Runtime | None:
|
||||
key = raw.strip().lower()
|
||||
return RUNTIME_ALIASES.get(key) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _infer_from_entry_files(task_dir: Path) -> TaskManifest | None:
|
||||
for entry, runtime in ENTRY_TO_RUNTIME.items():
|
||||
if (task_dir / entry).is_file():
|
||||
return TaskManifest(runtime=runtime, entry=entry)
|
||||
return None
|
||||
|
||||
|
||||
def load_task_manifest(task_dir: Path) -> TaskManifest:
|
||||
"""读取 task.json;缺失时按入口文件推断;默认 python + run.py。"""
|
||||
manifest_file = task_dir / "task.json"
|
||||
if manifest_file.is_file():
|
||||
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
runtime_raw = str(data.get("runtime", "python"))
|
||||
runtime = _normalize_runtime(runtime_raw)
|
||||
if runtime is None:
|
||||
raise ValueError(f"不支持的 runtime: {runtime_raw}")
|
||||
entry = str(data.get("entry", RUNTIME_DEFAULT_ENTRY[runtime])).strip()
|
||||
if not entry:
|
||||
entry = RUNTIME_DEFAULT_ENTRY[runtime]
|
||||
entry_path = task_dir / entry
|
||||
if not entry_path.is_file():
|
||||
raise FileNotFoundError(f"task.json 指定的入口不存在: {entry}")
|
||||
return TaskManifest(runtime=runtime, entry=entry)
|
||||
|
||||
inferred = _infer_from_entry_files(task_dir)
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
|
||||
if (task_dir / "run.py").is_file():
|
||||
return TaskManifest(runtime="python", entry="run.py")
|
||||
|
||||
raise FileNotFoundError("未找到 task.json 或可识别的入口脚本")
|
||||
|
||||
|
||||
def normalize_tags(raw: object) -> list[str]:
|
||||
"""去重、去空白,最多 MAX_TASK_TAGS 个。"""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return []
|
||||
tags: list[str] = []
|
||||
for item in raw:
|
||||
s = str(item).strip()
|
||||
if not s or len(s) > MAX_TAG_LENGTH:
|
||||
continue
|
||||
if s not in tags:
|
||||
tags.append(s)
|
||||
if len(tags) >= MAX_TASK_TAGS:
|
||||
break
|
||||
return tags
|
||||
|
||||
|
||||
def load_task_tags(task_dir: Path) -> list[str]:
|
||||
manifest_file = task_dir / "task.json"
|
||||
if not manifest_file.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return normalize_tags(data.get("tags", []))
|
||||
|
||||
|
||||
def effective_task_tags(task_dir: Path, manifest: TaskManifest) -> list[str]:
|
||||
"""已存标签优先;否则默认用运行时名称作为唯一标签。"""
|
||||
stored = load_task_tags(task_dir)
|
||||
if stored:
|
||||
return stored
|
||||
return [RUNTIME_LABEL.get(manifest.runtime, manifest.runtime)]
|
||||
|
||||
|
||||
def save_task_tags(task_dir: Path, tags: list[str]) -> None:
|
||||
"""写入 task.json 的 tags 字段;无文件时按当前 manifest 创建。"""
|
||||
manifest = load_task_manifest(task_dir)
|
||||
normalized = normalize_tags(tags)
|
||||
manifest_file = task_dir / "task.json"
|
||||
data: dict[str, object] = {}
|
||||
if manifest_file.is_file():
|
||||
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
data["runtime"] = manifest.runtime
|
||||
data["entry"] = manifest.entry
|
||||
data["tags"] = normalized
|
||||
manifest_file.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def is_valid_task_dir(task_dir: Path) -> bool:
|
||||
try:
|
||||
if not task_dir.is_dir():
|
||||
return False
|
||||
if not (task_dir / "schedule.cron").is_file():
|
||||
return False
|
||||
load_task_manifest(task_dir)
|
||||
return True
|
||||
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError):
|
||||
return False
|
||||
5
lib/vendor/lark-notice-api/src/lark_notice_api/__init__.py
vendored
Normal file
5
lib/vendor/lark-notice-api/src/lark_notice_api/__init__.py
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
7
lib/vendor/lark-notice-api/src/lark_notice_api/__main__.py
vendored
Normal file
7
lib/vendor/lark-notice-api/src/lark_notice_api/__main__.py
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
115
lib/vendor/lark-notice-api/src/lark_notice_api/cli.py
vendored
Normal file
115
lib/vendor/lark-notice-api/src/lark_notice_api/cli.py
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .client import LarkNoticeError, LarkWebhookClient
|
||||
|
||||
|
||||
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, float | None]:
|
||||
cleaned: list[str] = []
|
||||
webhook: str | None = None
|
||||
timeout: float | None = None
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--webhook" and i + 1 < len(argv):
|
||||
webhook = argv[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if arg == "--timeout" and i + 1 < len(argv):
|
||||
timeout = float(argv[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
cleaned.append(arg)
|
||||
i += 1
|
||||
return cleaned, webhook, timeout
|
||||
|
||||
|
||||
def _client_from_args(args: argparse.Namespace) -> LarkWebhookClient:
|
||||
webhook = args.webhook or os.getenv("LARK_NOTICE_WEBHOOK", "")
|
||||
if not webhook:
|
||||
raise LarkNoticeError("缺少 webhook,请传 --webhook 或设置 LARK_NOTICE_WEBHOOK")
|
||||
return LarkWebhookClient(webhook=webhook, timeout=args.timeout)
|
||||
|
||||
|
||||
def _normalize_cli_markdown(markdown: str) -> str:
|
||||
if "\n" in markdown or "\r" in markdown:
|
||||
return markdown
|
||||
return markdown.replace(r"\n", "\n").replace(r"\r", "\r")
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="lark-notice-api")
|
||||
parser.add_argument("--webhook", help="Feishu webhook URL")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout seconds")
|
||||
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
text = sub.add_parser("send-text", help="Send plain text message")
|
||||
text.add_argument("--text", required=True)
|
||||
|
||||
post = sub.add_parser("send-post", help="Send Feishu post message")
|
||||
post.add_argument("--title", required=True)
|
||||
post.add_argument("--line", action="append", dest="lines", required=True)
|
||||
|
||||
md = sub.add_parser("send-markdown", help="Send interactive markdown card")
|
||||
md.add_argument("--title", required=True)
|
||||
md.add_argument("--markdown", required=True)
|
||||
|
||||
demo = sub.add_parser("demo", help="Send a markdown demo card")
|
||||
demo.add_argument("--title", default="飞书消息示例")
|
||||
demo.add_argument(
|
||||
"--markdown",
|
||||
default=(
|
||||
"# 飞书 Markdown 通知测试\n"
|
||||
"\n"
|
||||
"这是一条 **JSON 2.0** 富文本消息。\n"
|
||||
"\n"
|
||||
"- 支持 *斜体*、**粗体**、~~删除线~~\n"
|
||||
"- [打开示例网站](https://feishu.cn)\n"
|
||||
"- 代码块示例:\n"
|
||||
"\n"
|
||||
"```json\n"
|
||||
"{\"hello\": \"world\"}\n"
|
||||
"```"
|
||||
),
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _emit(result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
raw_argv = sys.argv[1:] if argv is None else argv
|
||||
cleaned_argv, webhook, timeout = _split_global_args(raw_argv)
|
||||
args = _parser().parse_args(cleaned_argv)
|
||||
if webhook is not None:
|
||||
args.webhook = webhook
|
||||
if timeout is not None:
|
||||
args.timeout = timeout
|
||||
|
||||
try:
|
||||
client = _client_from_args(args)
|
||||
if args.command == "send-text":
|
||||
result = client.send_text(args.text)
|
||||
elif args.command == "send-post":
|
||||
result = client.send_post(args.title, args.lines)
|
||||
elif args.command == "send-markdown":
|
||||
result = client.send_markdown(args.title, _normalize_cli_markdown(args.markdown))
|
||||
elif args.command == "demo":
|
||||
result = client.send_markdown(args.title, args.markdown)
|
||||
else:
|
||||
raise LarkNoticeError(f"未知命令: {args.command}")
|
||||
except LarkNoticeError as exc:
|
||||
sys.stderr.write(f"ERROR: {exc}\n")
|
||||
return 2
|
||||
|
||||
_emit(result)
|
||||
return 0
|
||||
87
lib/vendor/lark-notice-api/src/lark_notice_api/client.py
vendored
Normal file
87
lib/vendor/lark-notice-api/src/lark_notice_api/client.py
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
|
||||
class LarkNoticeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LarkWebhookClient:
|
||||
webhook: str
|
||||
timeout: float = 10.0
|
||||
|
||||
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = request.Request(
|
||||
self.webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
except error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
||||
raise LarkNoticeError(f"HTTP {exc.code}: {body or exc.reason}") from exc
|
||||
except error.URLError as exc:
|
||||
raise LarkNoticeError(f"网络错误: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise LarkNoticeError(f"响应不是 JSON: {body}") from None
|
||||
|
||||
if result.get("code") not in (0, None):
|
||||
raise LarkNoticeError(
|
||||
f"飞书返回错误: code={result.get('code')} msg={result.get('msg') or result.get('message')}"
|
||||
)
|
||||
return result
|
||||
|
||||
def send_text(self, text: str) -> dict[str, Any]:
|
||||
return self._post({"msg_type": "text", "content": {"text": text}})
|
||||
|
||||
def send_post(self, title: str, lines: list[str]) -> dict[str, Any]:
|
||||
content = [[{"tag": "text", "text": line}] for line in lines]
|
||||
return self._post(
|
||||
{
|
||||
"msg_type": "post",
|
||||
"content": {
|
||||
"post": {
|
||||
"zh-CN": {
|
||||
"title": title,
|
||||
"content": content,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_markdown(markdown: str) -> str:
|
||||
return markdown.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
def send_markdown(self, title: str, markdown: str) -> dict[str, Any]:
|
||||
return self._post(
|
||||
{
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"schema": "2.0",
|
||||
"config": {"wide_screen_mode": True, "enable_forward": True},
|
||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": self._normalize_markdown(markdown),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
3
lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py
vendored
Normal file
3
lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
7
lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py
vendored
Normal file
7
lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
262
lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py
vendored
Normal file
262
lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .config import ConfigError, MailConfig
|
||||
from .email_client import MailClient, MailClientError
|
||||
from .templates import TemplateError, list_templates, render_template
|
||||
|
||||
|
||||
def _load_env_file(path: str | None) -> None:
|
||||
if path:
|
||||
os.environ["MENGYA_MAIL_ENV_FILE"] = path
|
||||
|
||||
|
||||
|
||||
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, str | None]:
|
||||
cleaned: list[str] = []
|
||||
env_file: str | None = None
|
||||
output_format: str | None = None
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--env-file" and i + 1 < len(argv):
|
||||
env_file = argv[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if arg == "--format" and i + 1 < len(argv):
|
||||
output_format = argv[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
cleaned.append(arg)
|
||||
i += 1
|
||||
return cleaned, env_file, output_format
|
||||
|
||||
|
||||
def _client() -> MailClient:
|
||||
return MailClient(MailConfig.from_env())
|
||||
|
||||
|
||||
def _parse_var_pairs(values: list[str] | None) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for item in values or []:
|
||||
if "=" not in item:
|
||||
raise ValueError(f"变量格式应为 key=value: {item}")
|
||||
key, value = item.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError(f"变量键不能为空: {item}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="mengya-mail-api")
|
||||
parser.add_argument("--env-file", help="Load env vars from a specific .env file")
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=("json", "md"),
|
||||
default="json",
|
||||
help="Output format",
|
||||
)
|
||||
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("test-connection", help="Check SMTP/IMAP connectivity")
|
||||
|
||||
send = sub.add_parser("send-email", help="Send a mail")
|
||||
send.add_argument("--to", nargs="+", required=True, help="Primary recipients")
|
||||
send.add_argument("--subject", required=True, help="Subject")
|
||||
send.add_argument("--text-body", help="Plain text body")
|
||||
send.add_argument("--html-body", help="HTML body")
|
||||
send.add_argument("--cc", nargs="+", help="CC recipients")
|
||||
send.add_argument("--bcc", nargs="+", help="BCC recipients")
|
||||
send.add_argument("--from-name", help="Display name of the sender")
|
||||
|
||||
listed = sub.add_parser("list-emails", help="List messages")
|
||||
listed.add_argument("--folder", help="IMAP folder")
|
||||
listed.add_argument("--criteria", nargs="+", help="IMAP search criteria")
|
||||
listed.add_argument("--subject", help="Filter by subject")
|
||||
listed.add_argument("--from-address", dest="from_address", help="Filter by sender")
|
||||
listed.add_argument("--to-address", dest="to_address", help="Filter by recipient")
|
||||
listed.add_argument("--limit", type=int, default=10, help="Max messages")
|
||||
listed.add_argument("--mark-seen", action="store_true", help="Mark as seen")
|
||||
|
||||
read = sub.add_parser("read-email", help="Read one message by UID")
|
||||
read.add_argument("--uid", required=True, help="Message UID")
|
||||
read.add_argument("--folder", help="IMAP folder")
|
||||
read.add_argument("--mark-seen", action="store_true", help="Mark as seen")
|
||||
|
||||
tmpl = sub.add_parser("send-template", help="Send a template mail")
|
||||
tmpl.add_argument("--template", required=True, choices=sorted(list(item["key"] for item in list_templates())), help="Template key")
|
||||
tmpl.add_argument("--to", nargs="+", required=True, help="Primary recipients")
|
||||
tmpl.add_argument("--var", action="append", help="Template variable key=value")
|
||||
tmpl.add_argument("--cc", nargs="+", help="CC recipients")
|
||||
tmpl.add_argument("--bcc", nargs="+", help="BCC recipients")
|
||||
tmpl.add_argument("--from-name", help="Display name of the sender")
|
||||
|
||||
sub.add_parser("templates", help="List template keys")
|
||||
return parser
|
||||
|
||||
|
||||
def _to_markdown(command: str, result: dict[str, Any]) -> str:
|
||||
if command == "test-connection":
|
||||
lines = [
|
||||
"# Mail Connection",
|
||||
"",
|
||||
f"- Address: {result.get('address', '')}",
|
||||
f"- SMTP: {result.get('smtp', '')}",
|
||||
f"- IMAP: {result.get('imap', '')}",
|
||||
f"- Ready: {result.get('ready', False)}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
if command == "templates":
|
||||
templates = result.get("templates") or []
|
||||
lines = ["# Templates", "", f"- Count: {result.get('count', 0)}"]
|
||||
for item in templates:
|
||||
lines.append(f"- {item.get('key', '')}: {item.get('title', '')}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
if command in {"send-email", "send-template"}:
|
||||
lines = [
|
||||
"# Mail Sent",
|
||||
"",
|
||||
f"- Status: {result.get('status', '')}",
|
||||
f"- From: {result.get('from', '')}",
|
||||
f"- To: {', '.join(result.get('to') or [])}",
|
||||
f"- Subject: {result.get('subject', '')}",
|
||||
]
|
||||
if result.get("template"):
|
||||
lines.append(f"- Template: {result.get('template')}")
|
||||
if result.get("date"):
|
||||
lines.append(f"- Date: {result.get('date')}")
|
||||
if result.get("message_id"):
|
||||
lines.append(f"- Message-ID: {result.get('message_id')}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
if command == "list-emails":
|
||||
lines = [
|
||||
"# Mail List",
|
||||
"",
|
||||
f"- Folder: {result.get('folder', '')}",
|
||||
f"- Count: {result.get('count', 0)}",
|
||||
]
|
||||
for item in result.get("messages") or []:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"## {item.get('subject', '(no subject)')}",
|
||||
"",
|
||||
f"- UID: {item.get('uid', '')}",
|
||||
f"- From: {item.get('from', '')}",
|
||||
f"- To: {item.get('to', '')}",
|
||||
f"- Date: {item.get('date', '') or ''}",
|
||||
f"- Seen: {item.get('seen', False)}",
|
||||
f"- Attachments: {item.get('has_attachments', False)}",
|
||||
f"- Snippet: {item.get('snippet', '')}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
if command == "read-email":
|
||||
lines = [
|
||||
"# Mail Detail",
|
||||
"",
|
||||
f"- UID: {result.get('uid', '')}",
|
||||
f"- Subject: {result.get('subject', '')}",
|
||||
f"- From: {result.get('from', '')}",
|
||||
f"- To: {result.get('to', '')}",
|
||||
f"- CC: {result.get('cc', '')}",
|
||||
f"- Date: {result.get('date', '') or ''}",
|
||||
f"- Message-ID: {result.get('message_id', '')}",
|
||||
f"- Seen: {result.get('seen', False)}",
|
||||
f"- Attachments: {result.get('has_attachments', False)}",
|
||||
]
|
||||
attachments = result.get("attachments") or []
|
||||
if attachments:
|
||||
lines.extend(["", "## Attachments", ""] + [f"- {item}" for item in attachments])
|
||||
text_body = result.get("text_body") or ""
|
||||
html_body = result.get("html_body") or ""
|
||||
if text_body:
|
||||
lines.extend(["", "## Text Body", "", text_body])
|
||||
if html_body:
|
||||
lines.extend(["", "## HTML Body", "", html_body])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def _emit(command: str, result: dict[str, Any], fmt: str) -> None:
|
||||
if fmt == "md":
|
||||
sys.stdout.write(_to_markdown(command, result))
|
||||
else:
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
raw_argv = sys.argv[1:] if argv is None else argv
|
||||
cleaned_argv, env_file, output_format = _split_global_args(raw_argv)
|
||||
args = _build_parser().parse_args(cleaned_argv)
|
||||
_load_env_file(env_file or args.env_file)
|
||||
|
||||
try:
|
||||
client = _client()
|
||||
if args.command == "test-connection":
|
||||
result = client.test_connection()
|
||||
elif args.command == "send-email":
|
||||
result = client.send_email(
|
||||
to=args.to,
|
||||
subject=args.subject,
|
||||
text_body=args.text_body,
|
||||
html_body=args.html_body,
|
||||
cc=args.cc,
|
||||
bcc=args.bcc,
|
||||
from_name=args.from_name,
|
||||
)
|
||||
elif args.command == "list-emails":
|
||||
result = client.list_emails(
|
||||
folder=args.folder,
|
||||
criteria=args.criteria,
|
||||
limit=args.limit,
|
||||
mark_seen=args.mark_seen,
|
||||
subject=args.subject,
|
||||
from_address=args.from_address,
|
||||
to_address=args.to_address,
|
||||
)
|
||||
elif args.command == "read-email":
|
||||
result = client.read_email(uid=args.uid, folder=args.folder, mark_seen=args.mark_seen)
|
||||
elif args.command == "send-template":
|
||||
variables = _parse_var_pairs(args.var)
|
||||
subject, text_body, html_body = render_template(args.template, variables)
|
||||
result = client.send_email(
|
||||
to=args.to,
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
cc=args.cc,
|
||||
bcc=args.bcc,
|
||||
from_name=args.from_name,
|
||||
)
|
||||
result["template"] = args.template
|
||||
elif args.command == "templates":
|
||||
template_items = list_templates()
|
||||
result = {"count": len(template_items), "templates": template_items}
|
||||
else:
|
||||
sys.stderr.write(f"ERROR: unsupported command: {args.command}\n")
|
||||
return 2
|
||||
except (ConfigError, MailClientError, TemplateError, ValueError) as exc:
|
||||
sys.stderr.write(f"ERROR: {exc}\n")
|
||||
return 2
|
||||
|
||||
final_format = output_format or args.format
|
||||
_emit(args.command, result, final_format)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
95
lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py
vendored
Normal file
95
lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _candidate_env_files() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
|
||||
explicit = os.getenv("MENGYA_MAIL_ENV_FILE")
|
||||
if explicit:
|
||||
candidates.append(Path(explicit).expanduser())
|
||||
|
||||
cwd_env = Path.cwd() / ".env"
|
||||
package_env = Path(__file__).resolve().parents[2] / ".env"
|
||||
|
||||
candidates.extend([cwd_env, package_env])
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].strip()
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = _strip_quotes(value.strip())
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_env_if_present() -> None:
|
||||
for candidate in _candidate_env_files():
|
||||
_load_env_file(candidate)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MailConfig:
|
||||
address: str
|
||||
password: str
|
||||
smtp_host: str = "smtp.qiye.aliyun.com"
|
||||
smtp_port: int = 465
|
||||
imap_host: str = "imap.qiye.aliyun.com"
|
||||
imap_port: int = 993
|
||||
default_folder: str = "INBOX"
|
||||
timeout_seconds: float = 30.0
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MailConfig":
|
||||
load_env_if_present()
|
||||
|
||||
address = os.getenv("MENGYA_MAIL_ADDRESS") or os.getenv("ALIYUN_MAIL_ADDRESS")
|
||||
password = os.getenv("MENGYA_MAIL_PASSWORD") or os.getenv("ALIYUN_MAIL_PASSWORD")
|
||||
if not address:
|
||||
raise ConfigError("缺少环境变量 MENGYA_MAIL_ADDRESS")
|
||||
if not password:
|
||||
raise ConfigError("缺少环境变量 MENGYA_MAIL_PASSWORD")
|
||||
|
||||
return cls(
|
||||
address=address,
|
||||
password=password,
|
||||
smtp_host=os.getenv("MENGYA_MAIL_SMTP_HOST", "smtp.qiye.aliyun.com"),
|
||||
smtp_port=int(os.getenv("MENGYA_MAIL_SMTP_PORT", "465")),
|
||||
imap_host=os.getenv("MENGYA_MAIL_IMAP_HOST", "imap.qiye.aliyun.com"),
|
||||
imap_port=int(os.getenv("MENGYA_MAIL_IMAP_PORT", "993")),
|
||||
default_folder=os.getenv("MENGYA_MAIL_DEFAULT_FOLDER", "INBOX"),
|
||||
timeout_seconds=float(os.getenv("MENGYA_MAIL_TIMEOUT_SECONDS", "30")),
|
||||
)
|
||||
425
lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py
vendored
Normal file
425
lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py
vendored
Normal file
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
from email.message import EmailMessage, Message
|
||||
from email.utils import formataddr, formatdate, make_msgid, parsedate_to_datetime
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
from .config import MailConfig
|
||||
|
||||
|
||||
class MailClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailSummary:
|
||||
uid: str
|
||||
subject: str
|
||||
from_address: str
|
||||
to: str
|
||||
date: str | None
|
||||
seen: bool
|
||||
has_attachments: bool
|
||||
snippet: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": self.uid,
|
||||
"subject": self.subject,
|
||||
"from": self.from_address,
|
||||
"to": self.to,
|
||||
"date": self.date,
|
||||
"seen": self.seen,
|
||||
"has_attachments": self.has_attachments,
|
||||
"snippet": self.snippet,
|
||||
}
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._chunks: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if data:
|
||||
self._chunks.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(chunk.strip() for chunk in self._chunks if chunk.strip())
|
||||
|
||||
|
||||
def _decode_header_value(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return str(make_header(decode_header(value)))
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_recipients(value: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
items = [part.strip() for part in value.split(",")]
|
||||
return [item for item in items if item]
|
||||
return [item.strip() for item in value if item and item.strip()]
|
||||
|
||||
|
||||
def _strip_html(html: str) -> str:
|
||||
parser = _HTMLTextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return parser.text()
|
||||
|
||||
|
||||
def _clean_text(text: str, limit: int | None = None) -> str:
|
||||
normalized = re.sub(r"\s+", " ", text).strip()
|
||||
if limit is not None and len(normalized) > limit:
|
||||
return normalized[: limit - 1] + "…"
|
||||
return normalized
|
||||
|
||||
|
||||
def _pick_text_part(message: Message) -> tuple[str, str]:
|
||||
text_body = ""
|
||||
html_body = ""
|
||||
|
||||
if message.is_multipart():
|
||||
for part in message.walk():
|
||||
disposition = part.get_content_disposition()
|
||||
if disposition == "attachment":
|
||||
continue
|
||||
content_type = part.get_content_type()
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
continue
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
body = payload.decode(charset, errors="replace")
|
||||
except LookupError:
|
||||
body = payload.decode("utf-8", errors="replace")
|
||||
if content_type == "text/plain" and not text_body:
|
||||
text_body = body
|
||||
elif content_type == "text/html" and not html_body:
|
||||
html_body = body
|
||||
else:
|
||||
payload = message.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
charset = message.get_content_charset() or "utf-8"
|
||||
try:
|
||||
text_body = payload.decode(charset, errors="replace")
|
||||
except LookupError:
|
||||
text_body = payload.decode("utf-8", errors="replace")
|
||||
|
||||
if not text_body and html_body:
|
||||
text_body = _strip_html(html_body)
|
||||
return text_body.strip(), html_body.strip()
|
||||
|
||||
|
||||
def _has_attachments(message: Message) -> bool:
|
||||
for part in message.walk():
|
||||
if part.get_content_disposition() == "attachment":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _attachment_names(message: Message) -> list[str]:
|
||||
names: list[str] = []
|
||||
for part in message.walk():
|
||||
if part.get_content_disposition() == "attachment":
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
names.append(_decode_header_value(filename))
|
||||
return names
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = parsedate_to_datetime(value)
|
||||
if isinstance(dt, datetime):
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _contains(haystack: str, needle: str | None) -> bool:
|
||||
if not needle:
|
||||
return True
|
||||
return needle.casefold() in haystack.casefold()
|
||||
|
||||
|
||||
class MailClient:
|
||||
def __init__(self, config: MailConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
*,
|
||||
to: str | list[str],
|
||||
subject: str,
|
||||
text_body: str | None = None,
|
||||
html_body: str | None = None,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
from_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
to_list = _normalize_recipients(to)
|
||||
cc_list = _normalize_recipients(cc)
|
||||
bcc_list = _normalize_recipients(bcc)
|
||||
all_recipients = to_list + cc_list + bcc_list
|
||||
if not to_list:
|
||||
raise MailClientError("至少需要一个主收件人")
|
||||
if not all_recipients:
|
||||
raise MailClientError("至少需要一个收件人")
|
||||
if not text_body and not html_body:
|
||||
raise MailClientError("text_body 与 html_body 至少提供一个")
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = formataddr((from_name, self.config.address)) if from_name else self.config.address
|
||||
message["To"] = ", ".join(to_list)
|
||||
if cc_list:
|
||||
message["Cc"] = ", ".join(cc_list)
|
||||
message["Subject"] = subject
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid(domain=self.config.address.split("@", 1)[-1])
|
||||
|
||||
if text_body:
|
||||
message.set_content(text_body)
|
||||
elif html_body:
|
||||
message.set_content(_strip_html(html_body) or "请查看 HTML 正文")
|
||||
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
try:
|
||||
with smtplib.SMTP_SSL(
|
||||
self.config.smtp_host,
|
||||
self.config.smtp_port,
|
||||
timeout=self.config.timeout_seconds,
|
||||
) as smtp:
|
||||
smtp.login(self.config.address, self.config.password)
|
||||
smtp.send_message(message, from_addr=self.config.address, to_addrs=all_recipients)
|
||||
except Exception as exc:
|
||||
raise MailClientError(f"SMTP 发送失败: {exc}") from exc
|
||||
|
||||
return {
|
||||
"status": "sent",
|
||||
"from": message["From"],
|
||||
"to": to_list,
|
||||
"cc": cc_list,
|
||||
"bcc": bcc_list,
|
||||
"subject": subject,
|
||||
"date": _parse_date(message["Date"]),
|
||||
"message_id": message["Message-ID"],
|
||||
}
|
||||
|
||||
def test_connection(self) -> dict[str, Any]:
|
||||
smtp_status = "ok"
|
||||
imap_status = "ok"
|
||||
|
||||
try:
|
||||
with smtplib.SMTP_SSL(
|
||||
self.config.smtp_host,
|
||||
self.config.smtp_port,
|
||||
timeout=self.config.timeout_seconds,
|
||||
) as smtp:
|
||||
smtp.login(self.config.address, self.config.password)
|
||||
except Exception as exc:
|
||||
smtp_status = f"failed: {exc}"
|
||||
|
||||
try:
|
||||
with imaplib.IMAP4_SSL(
|
||||
self.config.imap_host,
|
||||
self.config.imap_port,
|
||||
timeout=self.config.timeout_seconds,
|
||||
) as imap:
|
||||
imap.login(self.config.address, self.config.password)
|
||||
except Exception as exc:
|
||||
imap_status = f"failed: {exc}"
|
||||
|
||||
return {
|
||||
"address": self.config.address,
|
||||
"smtp": smtp_status,
|
||||
"imap": imap_status,
|
||||
"ready": smtp_status == "ok" and imap_status == "ok",
|
||||
}
|
||||
|
||||
def list_emails(
|
||||
self,
|
||||
*,
|
||||
folder: str | None = None,
|
||||
criteria: str | list[str] | None = None,
|
||||
limit: int = 10,
|
||||
mark_seen: bool = False,
|
||||
subject: str | None = None,
|
||||
from_address: str | None = None,
|
||||
to_address: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
selected_folder = folder or self.config.default_folder
|
||||
if limit < 1:
|
||||
raise MailClientError("limit 必须大于 0")
|
||||
|
||||
use_client_side_filters = any([subject, from_address, to_address])
|
||||
base_search_terms = self._build_base_search_terms(criteria=criteria, use_all=use_client_side_filters)
|
||||
|
||||
try:
|
||||
with imaplib.IMAP4_SSL(
|
||||
self.config.imap_host,
|
||||
self.config.imap_port,
|
||||
timeout=self.config.timeout_seconds,
|
||||
) as imap:
|
||||
imap.login(self.config.address, self.config.password)
|
||||
status, _ = imap.select(selected_folder, readonly=not mark_seen)
|
||||
if status != "OK":
|
||||
raise MailClientError(f"无法选择文件夹 {selected_folder}")
|
||||
|
||||
status, data = imap.uid("search", None, *base_search_terms)
|
||||
if status != "OK":
|
||||
raise MailClientError(f"IMAP 搜索失败: {' '.join(base_search_terms)}")
|
||||
|
||||
uids = [uid.decode() for uid in data[0].split()] if data and data[0] else []
|
||||
candidate_limit = max(limit * 10, 50) if use_client_side_filters else limit
|
||||
selected_uids = list(reversed(uids[-candidate_limit:]))
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for uid in selected_uids:
|
||||
summary = self._fetch_summary(imap, uid, mark_seen=mark_seen)
|
||||
if use_client_side_filters and not self._matches_summary_filters(
|
||||
summary,
|
||||
subject=subject,
|
||||
from_address=from_address,
|
||||
to_address=to_address,
|
||||
):
|
||||
continue
|
||||
messages.append(summary.to_dict())
|
||||
if len(messages) >= limit:
|
||||
break
|
||||
except MailClientError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MailClientError(f"IMAP 列取邮件失败: {exc}") from exc
|
||||
|
||||
return {
|
||||
"folder": selected_folder,
|
||||
"criteria": base_search_terms,
|
||||
"count": len(messages),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
def read_email(
|
||||
self,
|
||||
*,
|
||||
uid: str,
|
||||
folder: str | None = None,
|
||||
mark_seen: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
selected_folder = folder or self.config.default_folder
|
||||
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
|
||||
|
||||
try:
|
||||
with imaplib.IMAP4_SSL(
|
||||
self.config.imap_host,
|
||||
self.config.imap_port,
|
||||
timeout=self.config.timeout_seconds,
|
||||
) as imap:
|
||||
imap.login(self.config.address, self.config.password)
|
||||
status, _ = imap.select(selected_folder, readonly=not mark_seen)
|
||||
if status != "OK":
|
||||
raise MailClientError(f"无法选择文件夹 {selected_folder}")
|
||||
status, data = imap.uid("fetch", uid, fetch_query)
|
||||
if status != "OK" or not data or data[0] is None:
|
||||
raise MailClientError(f"找不到 UID={uid} 的邮件")
|
||||
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
|
||||
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
|
||||
message = email.message_from_bytes(raw_message, policy=policy.default)
|
||||
except MailClientError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MailClientError(f"读取邮件失败: {exc}") from exc
|
||||
|
||||
text_body, html_body = _pick_text_part(message)
|
||||
return {
|
||||
"uid": uid,
|
||||
"subject": _decode_header_value(message.get("Subject")),
|
||||
"from": _decode_header_value(message.get("From")),
|
||||
"to": _decode_header_value(message.get("To")),
|
||||
"cc": _decode_header_value(message.get("Cc")),
|
||||
"date": _parse_date(message.get("Date")),
|
||||
"message_id": _decode_header_value(message.get("Message-ID")),
|
||||
"seen": "\\Seen" in metadata,
|
||||
"has_attachments": _has_attachments(message),
|
||||
"attachments": _attachment_names(message),
|
||||
"text_body": text_body[:20000],
|
||||
"html_body": html_body[:20000],
|
||||
}
|
||||
|
||||
def _fetch_summary(self, imap: imaplib.IMAP4_SSL, uid: str, *, mark_seen: bool) -> MailSummary:
|
||||
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
|
||||
status, data = imap.uid("fetch", uid, fetch_query)
|
||||
if status != "OK" or not data or data[0] is None:
|
||||
raise MailClientError(f"无法读取 UID={uid} 的邮件")
|
||||
|
||||
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
|
||||
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
|
||||
message = email.message_from_bytes(raw_message, policy=policy.default)
|
||||
text_body, html_body = _pick_text_part(message)
|
||||
snippet_source = text_body or _strip_html(html_body)
|
||||
return MailSummary(
|
||||
uid=uid,
|
||||
subject=_decode_header_value(message.get("Subject")),
|
||||
from_address=_decode_header_value(message.get("From")),
|
||||
to=_decode_header_value(message.get("To")),
|
||||
date=_parse_date(message.get("Date")),
|
||||
seen="\\Seen" in metadata,
|
||||
has_attachments=_has_attachments(message),
|
||||
snippet=_clean_text(snippet_source, limit=160),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_base_search_terms(*, criteria: str | list[str] | None, use_all: bool) -> list[str]:
|
||||
if criteria is None:
|
||||
return ["ALL"] if use_all else ["UNSEEN"]
|
||||
return MailClient._normalize_search_terms(criteria)
|
||||
|
||||
@staticmethod
|
||||
def _matches_summary_filters(
|
||||
summary: MailSummary,
|
||||
*,
|
||||
subject: str | None,
|
||||
from_address: str | None,
|
||||
to_address: str | None,
|
||||
) -> bool:
|
||||
return (
|
||||
_contains(summary.subject, subject)
|
||||
and _contains(summary.from_address, from_address)
|
||||
and _contains(summary.to, to_address)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_search_terms(criteria: str | list[str] | None) -> list[str]:
|
||||
if criteria is None:
|
||||
return ["UNSEEN"]
|
||||
if isinstance(criteria, str):
|
||||
parsed = shlex.split(criteria)
|
||||
return parsed or ["UNSEEN"]
|
||||
if not criteria:
|
||||
return ["UNSEEN"]
|
||||
return [item for item in criteria if item]
|
||||
|
||||
|
||||
def format_result(data: dict[str, Any]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
170
lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py
vendored
Normal file
170
lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import __version__
|
||||
from .config import ConfigError, MailConfig
|
||||
from .email_client import MailClient, MailClientError
|
||||
from .templates import TemplateError, list_templates as get_template_list, render_template
|
||||
|
||||
|
||||
API_TOKEN_ENV = "MENGYA_MAIL_API_TOKEN"
|
||||
DEFAULT_API_TOKEN = "shumengya520"
|
||||
API_HOST_ENV = "MENGYA_MAIL_API_HOST"
|
||||
API_PORT_ENV = "MENGYA_MAIL_API_PORT"
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
to: str | list[str]
|
||||
subject: str
|
||||
text_body: str | None = None
|
||||
html_body: str | None = None
|
||||
cc: str | list[str] | None = None
|
||||
bcc: str | list[str] | None = None
|
||||
from_name: str | None = None
|
||||
|
||||
|
||||
class ListEmailsRequest(BaseModel):
|
||||
folder: str | None = None
|
||||
criteria: str | list[str] | None = None
|
||||
subject: str | None = None
|
||||
from_address: str | None = None
|
||||
to_address: str | None = None
|
||||
limit: int | None = None
|
||||
mark_seen: bool = False
|
||||
|
||||
|
||||
class ReadEmailRequest(BaseModel):
|
||||
uid: str
|
||||
folder: str | None = None
|
||||
mark_seen: bool = False
|
||||
|
||||
|
||||
class SendTemplateRequest(BaseModel):
|
||||
template: str
|
||||
to: str | list[str]
|
||||
variables: dict[str, str] | None = None
|
||||
cc: str | list[str] | None = None
|
||||
bcc: str | list[str] | None = None
|
||||
from_name: str | None = None
|
||||
|
||||
|
||||
def _get_api_token() -> str:
|
||||
return os.getenv(API_TOKEN_ENV, DEFAULT_API_TOKEN)
|
||||
|
||||
|
||||
def _extract_token(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header[7:].strip() or None
|
||||
return request.headers.get("x-auth-token")
|
||||
|
||||
|
||||
def require_token(request: Request) -> None:
|
||||
expected = _get_api_token()
|
||||
provided = _extract_token(request)
|
||||
if not provided or provided != expected:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def create_app(mail_client: MailClient | None = None) -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="萌芽邮箱 API",
|
||||
version=__version__,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
client = mail_client or MailClient(MailConfig.from_env())
|
||||
|
||||
@app.exception_handler(ConfigError)
|
||||
async def handle_config_error(_: Request, exc: ConfigError) -> JSONResponse:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
@app.exception_handler(MailClientError)
|
||||
async def handle_mail_error(_: Request, exc: MailClientError) -> JSONResponse:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/api/send-email")
|
||||
async def send_email(
|
||||
payload: SendEmailRequest,
|
||||
_: None = Depends(require_token),
|
||||
) -> dict[str, Any]:
|
||||
result = client.send_email(
|
||||
**payload.model_dump(exclude_none=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@app.post("/api/list-emails")
|
||||
async def list_emails(
|
||||
payload: ListEmailsRequest,
|
||||
_: None = Depends(require_token),
|
||||
) -> dict[str, Any]:
|
||||
result = client.list_emails(
|
||||
**payload.model_dump(exclude_none=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@app.post("/api/read-email")
|
||||
async def read_email(
|
||||
payload: ReadEmailRequest,
|
||||
_: None = Depends(require_token),
|
||||
) -> dict[str, Any]:
|
||||
result = client.read_email(
|
||||
**payload.model_dump(exclude_none=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@app.get("/api/test-connection")
|
||||
async def test_connection(_: None = Depends(require_token)) -> dict[str, Any]:
|
||||
return client.test_connection()
|
||||
|
||||
@app.get("/api/templates")
|
||||
async def list_templates(_: None = Depends(require_token)) -> dict[str, Any]:
|
||||
templates = get_template_list()
|
||||
return {"count": len(templates), "templates": templates}
|
||||
|
||||
@app.post("/api/send-template")
|
||||
async def send_template(
|
||||
payload: SendTemplateRequest,
|
||||
_: None = Depends(require_token),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
subject, text_body, html_body = render_template(payload.template, payload.variables)
|
||||
except TemplateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
result = client.send_email(
|
||||
to=payload.to,
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
cc=payload.cc,
|
||||
bcc=payload.bcc,
|
||||
from_name=payload.from_name,
|
||||
)
|
||||
result["template"] = payload.template
|
||||
return result
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv(API_HOST_ENV, "0.0.0.0")
|
||||
port = int(os.getenv(API_PORT_ENV, "8080"))
|
||||
uvicorn.run("mengya_mail_api.http_server:app", host=host, port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
77
lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py
vendored
Normal file
77
lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TEMPLATE_VARS = {
|
||||
"name": "朋友",
|
||||
"sender": "树萌芽",
|
||||
}
|
||||
|
||||
TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"birthday": {
|
||||
"title": "生日祝福",
|
||||
"subject": "生日快乐,{name}!",
|
||||
"text": (
|
||||
"亲爱的{name}:\n\n"
|
||||
"祝你生日快乐,愿新的一岁平安顺遂,心想事成!\n\n"
|
||||
"{sender}"
|
||||
),
|
||||
"html_file": "birthday.html",
|
||||
},
|
||||
"new_year": {
|
||||
"title": "元旦祝福",
|
||||
"subject": "元旦快乐,{name}!",
|
||||
"text": (
|
||||
"亲爱的{name}:\n\n"
|
||||
"新年伊始,愿你元旦快乐,万事顺遂,心想事成!\n\n"
|
||||
"{sender}"
|
||||
),
|
||||
"html_file": "new_year.html",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TemplateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def template_dir() -> Path:
|
||||
env_dir = os.getenv("MENGYA_MAIL_TEMPLATE_DIR")
|
||||
if env_dir:
|
||||
return Path(env_dir).expanduser()
|
||||
return Path(__file__).resolve().parents[2] / "template"
|
||||
|
||||
|
||||
def read_template_file(filename: str) -> str:
|
||||
path = template_dir() / filename
|
||||
if not path.is_file():
|
||||
raise TemplateError(f"Template file not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def render_template(template_key: str, variables: dict[str, str] | None = None) -> tuple[str, str, str | None]:
|
||||
template = TEMPLATES.get(template_key)
|
||||
if not template:
|
||||
raise TemplateError(f"Unknown template: {template_key}")
|
||||
|
||||
merged = {**DEFAULT_TEMPLATE_VARS, **(variables or {})}
|
||||
try:
|
||||
subject = template["subject"].format(**merged)
|
||||
text_body = template["text"].format(**merged)
|
||||
html_body = None
|
||||
html_file = template.get("html_file")
|
||||
if html_file:
|
||||
html_body = read_template_file(html_file).format(**merged)
|
||||
except KeyError as exc:
|
||||
missing = exc.args[0] if exc.args else "unknown"
|
||||
raise TemplateError(f"Missing template variable: {missing}") from exc
|
||||
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def list_templates() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"key": key, "title": value.get("title", key)}
|
||||
for key, value in TEMPLATES.items()
|
||||
]
|
||||
Reference in New Issue
Block a user