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:
11
.cursor/mcp.json
Normal file
11
.cursor/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"sproutclaw-cron": {
|
||||
"command": "python",
|
||||
"args": ["mcp-server/server.py"],
|
||||
"env": {
|
||||
"SPROUTCLAW_CRON_ROOT": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
.cursor/skills/sproutclaw-cron/SKILL.md
Normal file
88
.cursor/skills/sproutclaw-cron/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: sproutclaw-cron
|
||||
description: >-
|
||||
Manage SproutClaw Cron scheduled tasks: list/enable/disable/run tasks, read logs,
|
||||
update schedules, create tasks from templates. Use when the user mentions cronctl,
|
||||
sproutclaw-cron, scheduled tasks, cron jobs, task enable/disable, or creating
|
||||
new cron tasks. Prefer MCP tools (cron_*) when available; fall back to cronctl.py CLI.
|
||||
---
|
||||
|
||||
# SproutClaw Cron 定时任务技能
|
||||
|
||||
## 何时启用
|
||||
|
||||
用户提到:`cronctl`、定时任务、开关任务、试跑任务、新建 cron 任务、`sproutclaw-cron`、WebUI 管理面板,或需要查看任务日志/调度时。
|
||||
|
||||
## 优先顺序
|
||||
|
||||
1. **MCP 工具**(已配置 `.cursor/mcp.json`):`cron_list_tasks`、`cron_get_task`、`cron_run_task` 等
|
||||
2. **CLI 兜底**:`python cronctl.py <action> [task-id]`
|
||||
3. **WebUI**:`start-webui.bat`(Windows)或 `webui/start.sh`(Linux)
|
||||
|
||||
## 项目路径
|
||||
|
||||
- 根目录:仓库根(`cronctl.py` 所在目录)
|
||||
- 可通过环境变量 `SPROUTCLAW_CRON_ROOT` 覆盖
|
||||
- Linux 生产路径示例:`/shumengya/project/agent/sproutclaw-cron`
|
||||
|
||||
## MCP 工具速查
|
||||
|
||||
| 场景 | 工具 |
|
||||
|------|------|
|
||||
| 看有哪些任务 | `cron_list_tasks` |
|
||||
| 看单个任务详情 | `cron_get_task` |
|
||||
| 开/关/切换 | `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` |
|
||||
| 试跑 | `cron_run_task` → `cron_get_log` |
|
||||
| 改调度 | `cron_update_schedule` |
|
||||
| 新建任务 | `cron_list_templates` → `cron_create_task` |
|
||||
| 同步系统 cron | `cron_sync_cron` |
|
||||
|
||||
## 新建任务流程
|
||||
|
||||
1. 确认 `task_id` 命名:`<主机名>-<功能描述>`(如 `smallmengya-gitea-repo-sync`)
|
||||
2. `cron_create_task(task_id=..., runtime="python")` — **默认禁用**
|
||||
3. 编辑 `<task-id>/run.py`(或对应入口)与 `schedule.cron`
|
||||
4. `cron_run_task` 试跑,`cron_get_log` 确认
|
||||
5. 用户明确要求后再 `cron_enable_task`
|
||||
|
||||
模板与约定详见根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
|
||||
## CLI 兜底命令
|
||||
|
||||
```bash
|
||||
python cronctl.py status
|
||||
python cronctl.py status <task-id>
|
||||
python cronctl.py enable|disable|toggle <task-id>
|
||||
python cronctl.py run <task-id>
|
||||
python cronctl.py sync-cron <task-id>
|
||||
python cronctl.py enable all
|
||||
```
|
||||
|
||||
Windows 将 `python` 换为实际 Python 路径;Linux 可用 `python3`。
|
||||
|
||||
## Agent 原则
|
||||
|
||||
- **默认新建任务保持禁用**,除非用户明确要求启用
|
||||
- **不要修改** `_template*` 示例目录(除非用户要求)
|
||||
- **schedule.cron** 调度入口保持 `cronctl.py run <task-id>` 形式
|
||||
- Python 任务保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构
|
||||
- 非 Python 任务只改入口脚本;锁/日志/disable 由 `cronctl run` 统一处理
|
||||
- 改完业务逻辑后先 `run` 再 `enable`
|
||||
|
||||
## 任务目录结构
|
||||
|
||||
```
|
||||
<task-id>/
|
||||
├── task.json # 可选:runtime + entry + tags
|
||||
├── run.py|run.js|run.sh|run.ps1
|
||||
├── schedule.cron
|
||||
├── logs/<task-id>.log
|
||||
└── *.json # 可选配置
|
||||
```
|
||||
|
||||
禁用任务位于 `.disabled/<task-id>/`。
|
||||
|
||||
## 附加资源
|
||||
|
||||
- CLI 与 API 详情:[reference.md](reference.md)
|
||||
- 环境检查脚本:[scripts/check_cron.py](scripts/check_cron.py)
|
||||
83
.cursor/skills/sproutclaw-cron/reference.md
Normal file
83
.cursor/skills/sproutclaw-cron/reference.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# SproutClaw Cron 参考
|
||||
|
||||
## cronctl.py 子命令
|
||||
|
||||
| 子命令 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `status` | 列出或查看任务状态 | `cronctl.py status` |
|
||||
| `enable` | 启用并同步 cron | `cronctl.py enable my-task` |
|
||||
| `disable` | 禁用(移入 `.disabled/`) | `cronctl.py disable my-task` |
|
||||
| `toggle` | 切换状态 | `cronctl.py toggle my-task` |
|
||||
| `run` | 同步执行一次 | `cronctl.py run my-task` |
|
||||
| `sync-cron` | 仅同步 cron.d / schtasks | `cronctl.py sync-cron my-task` |
|
||||
|
||||
别名:`on` → `enable`,`off` → `disable`。批量操作传 `all`。
|
||||
|
||||
## task.json
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "python",
|
||||
"entry": "run.py",
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
```
|
||||
|
||||
`runtime`:`python` | `javascript` | `bash` | `powershell`
|
||||
|
||||
## schedule.cron 格式
|
||||
|
||||
- 首行注释为任务描述(WebUI / MCP 会读取)
|
||||
- cron 五段 + 用户 + 命令
|
||||
- 命令应调用:`python3 <CRON_ROOT>/cronctl.py run <task-id>`
|
||||
|
||||
示例:
|
||||
|
||||
```cron
|
||||
# 每天 08:00 同步仓库
|
||||
0 8 * * * root /usr/bin/python3 /path/to/sproutclaw-cron/cronctl.py run my-task >/dev/null 2>&1
|
||||
```
|
||||
|
||||
## WebUI API(FastAPI,端口见 start 脚本)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/tasks` | 任务列表 |
|
||||
| GET | `/api/tasks/{id}` | 任务详情 |
|
||||
| POST | `/api/tasks/{id}/enable` | 启用 |
|
||||
| POST | `/api/tasks/{id}/disable` | 禁用 |
|
||||
| POST | `/api/tasks/{id}/run` | 后台试跑 |
|
||||
| GET | `/api/tasks/{id}/log?lines=200` | 日志 |
|
||||
| PATCH | `/api/tasks/{id}/schedule` | 更新调度 |
|
||||
|
||||
## 语言模板
|
||||
|
||||
| 模板目录 | runtime | 入口 |
|
||||
|----------|---------|------|
|
||||
| `_template` | python | `run.py` |
|
||||
| `_template-javascript` | javascript | `run.js` |
|
||||
| `_template-bash` | bash | `run.sh` |
|
||||
| `_template-powershell` | powershell | `run.ps1` |
|
||||
|
||||
## 公共库(Python 任务)
|
||||
|
||||
从 `lib/runner.py` 导入:
|
||||
|
||||
- `TaskContext.from_task_id(task_id)`
|
||||
- `task_is_disabled(ctx)` / `task_logging(ctx)` / `acquire_cron_lock(...)`
|
||||
|
||||
从 `lib/notify.py` 导入:
|
||||
|
||||
- `TaskResult`、`send_task_summary(...)`
|
||||
|
||||
## 开关机制
|
||||
|
||||
- 启用:`.disabled/<id>/` → `<id>/`
|
||||
- 禁用:`<id>/` → `.disabled/<id>/`
|
||||
- 禁用后 cron 仍可触发,但 `cronctl run` 会直接跳过
|
||||
|
||||
## MCP 服务器
|
||||
|
||||
- 入口:`mcp-server/server.py`
|
||||
- 配置:`.cursor/mcp.json`
|
||||
- 依赖:`pip install -r mcp-server/requirements.txt`
|
||||
55
.cursor/skills/sproutclaw-cron/scripts/check_cron.py
Normal file
55
.cursor/skills/sproutclaw-cron/scripts/check_cron.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SproutClaw Cron 环境检查(跨平台,仅标准库 + 可选 fastmcp)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
lib = root / "lib"
|
||||
cronctl = root / "cronctl.py"
|
||||
mcp_server = root / "mcp-server" / "server.py"
|
||||
|
||||
checks: list[dict[str, object]] = []
|
||||
|
||||
def add(name: str, ok: bool, detail: str = "") -> None:
|
||||
checks.append({"name": name, "ok": ok, "detail": detail})
|
||||
|
||||
add("cron_root", root.is_dir(), str(root))
|
||||
add("cronctl.py", cronctl.is_file(), str(cronctl))
|
||||
add("lib/", lib.is_dir(), str(lib))
|
||||
add("mcp-server/server.py", mcp_server.is_file(), str(mcp_server))
|
||||
|
||||
try:
|
||||
import fastmcp # noqa: F401
|
||||
|
||||
add("fastmcp", True, "已安装")
|
||||
except ImportError:
|
||||
add("fastmcp", False, "pip install -r mcp-server/requirements.txt")
|
||||
|
||||
if cronctl.is_file():
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(cronctl), "status"],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
add(
|
||||
"cronctl status",
|
||||
proc.returncode == 0,
|
||||
(proc.stdout or proc.stderr).strip()[:500],
|
||||
)
|
||||
|
||||
ok_all = all(c["ok"] for c in checks if c["name"] != "fastmcp")
|
||||
print(json.dumps({"ok": ok_all, "checks": checks}, ensure_ascii=False, indent=2))
|
||||
return 0 if ok_all else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -13,6 +13,10 @@ logs/
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
# 内置邮件 API 凭据(本地 .env 不上传)
|
||||
lib/vendor/mengya-mail-api/.env
|
||||
lib/vendor/mengya-mail-api/.env.*
|
||||
|
||||
# 禁用任务目录(含本机专属脚本/凭据,不上传)
|
||||
.disabled/
|
||||
|
||||
@@ -27,3 +31,6 @@ smallmengya-gitea-repo-sync/*.lock
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# MCP 本地虚拟环境
|
||||
mcp-server/.venv/
|
||||
|
||||
57
AGENTS.md
57
AGENTS.md
@@ -2,37 +2,60 @@
|
||||
|
||||
## 新建定时任务
|
||||
|
||||
**必须**从 `_template/` 复制(`_template` 本身是可运行的 hello world 示例任务)。
|
||||
**默认从 `_template/`(Python)复制**;也可选用其他语言模板:
|
||||
|
||||
| 模板目录 | 语言 | 入口 |
|
||||
|---|---|---|
|
||||
| `_template` | Python(默认) | `run.py` |
|
||||
| `_template-javascript` | JavaScript | `run.js` |
|
||||
| `_template-bash` | Bash | `run.sh` |
|
||||
| `_template-powershell` | PowerShell | `run.ps1` |
|
||||
|
||||
```bash
|
||||
TASK_ID="<主机名>-<功能描述>"
|
||||
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
|
||||
TEMPLATE="_template" # 或 _template-javascript / _template-bash / _template-powershell
|
||||
|
||||
cp -a "$CRON_ROOT/$TEMPLATE" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/$TEMPLATE/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
# Bash 模板额外:chmod +x "$CRON_ROOT/$TASK_ID/run.sh"
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
1. 修改 `run.py`:保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构,替换 `hello world` 为实际逻辑
|
||||
2. 修改 `schedule.cron`:调整 cron 表达式与注释说明
|
||||
3. 可选:添加任务专属 JSON 配置(如 `targets.json`)
|
||||
4. 用 `python3 cronctl.py run <task-id>` 试跑,确认日志正常
|
||||
5. 默认保持关闭;用户要求启用时再 `cronctl enable <task-id>`
|
||||
1. **Python**:修改 `run.py`,保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构
|
||||
2. **其他语言**:只改入口脚本(`run.js` / `run.sh` / `run.ps1`);disable / 锁 / 日志由 `cronctl run` 统一处理
|
||||
3. 修改 `schedule.cron`:调整 cron 表达式与注释说明
|
||||
4. 可选:任务专属 JSON 配置(如 `targets.json`)
|
||||
5. 用 `python3 cronctl.py run <task-id>` 试跑,确认日志正常
|
||||
6. 默认保持关闭;用户要求启用时再 `cronctl enable <task-id>`
|
||||
|
||||
## task.json
|
||||
|
||||
可选清单,声明运行时与入口(无则按入口文件自动推断):
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "python",
|
||||
"entry": "run.py"
|
||||
}
|
||||
```
|
||||
|
||||
`runtime` 取值:`python` | `javascript` | `bash` | `powershell`
|
||||
|
||||
## 不要修改
|
||||
|
||||
- `_template/` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为
|
||||
- `_template*` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为
|
||||
- 不要删除或移动已有任务的 `schedule.cron` 里对 `cronctl.py run` 的调用方式
|
||||
|
||||
## 任务目录约定
|
||||
|
||||
```
|
||||
<task-id>/
|
||||
├── run.py
|
||||
├── task.json # 可选:runtime + entry
|
||||
├── run.py|run.js|run.sh|run.ps1
|
||||
├── schedule.cron
|
||||
├── switch.sh # 可选
|
||||
├── logs/<task-id>.log # 运行时自动创建
|
||||
├── logs/<task-id>.log
|
||||
└── *.json # 可选配置
|
||||
```
|
||||
|
||||
@@ -46,4 +69,10 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <task-id>
|
||||
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable <task-id>
|
||||
```
|
||||
|
||||
WebUI:`/shumengya/project/agent/sproutclaw-cron/webui/start.sh`
|
||||
WebUI:`/shumengya/project/agent/sproutclaw-cron/webui/start.sh` 或 Windows 下 `start-webui.bat`
|
||||
|
||||
## AI Agent 集成
|
||||
|
||||
- **Skill**:`.cursor/skills/sproutclaw-cron/SKILL.md` — 操作规范与 workflow
|
||||
- **MCP**:`.cursor/mcp.json` 注册 `sproutclaw-cron` 服务器;工具前缀 `cron_*`
|
||||
- MCP 依赖:`pip install -r mcp-server/requirements.txt`
|
||||
|
||||
80
README.md
80
README.md
@@ -10,16 +10,21 @@
|
||||
/shumengya/project/agent/sproutclaw-cron/
|
||||
├── cronctl.py # 统一管理 CLI(enable/disable/toggle/status)
|
||||
├── AGENTS.md # AI 新建任务规范(必读)
|
||||
├── _template/ # 示例任务 + 复制模板(每天 08:00 hello world)
|
||||
├── _template/ # Python 示例 + 默认复制模板
|
||||
├── _template-javascript/ # JavaScript (Node.js) 示例
|
||||
├── _template-bash/ # Bash 示例
|
||||
├── _template-powershell/ # PowerShell 示例
|
||||
├── lib/
|
||||
│ └── shumengya_cron/
|
||||
│ ├── runner.py # TaskContext、日志轮转、flock 互斥锁
|
||||
│ ├── runner.py # TaskContext、日志轮转、互斥锁
|
||||
│ ├── task_manifest.py # task.json 解析、runtime 推断
|
||||
│ ├── external_runner.py # 非 Python 任务 subprocess 启动
|
||||
│ ├── notify.py # 飞书 Markdown 通知 + 邮件降级
|
||||
│ └── ssh.py # 远端 SSH 辅助(bash -lc)
|
||||
├── <task-id>/ # 任务目录(启用状态)
|
||||
│ ├── run.py # 任务入口(唯一必须文件)
|
||||
│ ├── task.json # 可选:runtime + entry
|
||||
│ ├── run.py / run.js / run.sh / run.ps1
|
||||
│ ├── schedule.cron # 系统 cron 配置(复制到 /etc/cron.d/)
|
||||
│ ├── switch.sh # 一键开关(可用 cronctl 替代)
|
||||
│ ├── targets.json # 可选:任务自定义配置(JSON)
|
||||
│ ├── logs/<task-id>.log # 运行日志(自动创建)
|
||||
│ └── <task-id>.lock # flock 互斥锁文件(自动创建)
|
||||
@@ -31,19 +36,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 已有任务
|
||||
|
||||
| 任务 ID | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `_template` | 开启 | 示例任务:每天 08:00 输出 hello world,也是新任务复制模板 |
|
||||
| `bigmengya-docker-container-restart` | disabled | SSH 重启 bigmengya 上的数据库类容器 |
|
||||
| `bigmengya-docker-image-update` | disabled | SSH 拉取并重建 bigmengya 上的 compose 服务 |
|
||||
| `smallmengya-ai-cli-update` | disabled | 更新本机 codex / claude / opencode |
|
||||
| `smallmengya-ai-memory-export` | disabled | 导出 AI 记忆数据 |
|
||||
| `smallmengya-gitea-repo-sync` | 开启 | 同步 Gitea 仓库到本地 |
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 查看状态
|
||||
@@ -65,32 +57,66 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py disable all #
|
||||
# 推荐:通过 cronctl(自动识别 .disabled/ 下的任务)
|
||||
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <任务名>
|
||||
|
||||
# 也可直接运行(启用或 .disabled/<任务名> 目录均可)
|
||||
# 也可直接运行 Python 任务(启用或 .disabled/<任务名> 目录均可)
|
||||
python3 /shumengya/project/agent/sproutclaw-cron/.disabled/<任务名>/run.py
|
||||
# 其他语言任务请始终用 cronctl run
|
||||
```
|
||||
|
||||
### 安装到系统 cron
|
||||
|
||||
`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/<task-id>`;`disable` **只**把任务目录移入 `.disabled/<task-id>/`,**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但 `run.py` 检测到禁用后会直接跳过。
|
||||
**Linux**:`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/<task-id>`;`disable` **只**把任务目录移入 `.disabled/<task-id>/`,**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但禁用后任务会自动跳过。
|
||||
|
||||
```bash
|
||||
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py sync-cron all # 仅同步 cron.d,不改开关
|
||||
```
|
||||
|
||||
**Windows**:`enable` / `sync-cron` 会通过 `schtasks` 注册任务计划(任务名 `sproutclaw-<task-id>`)。目前自动转换 `分 时 * * *` 形式的 cron 表达式为「每天 HH:MM」;复杂表达式需手动创建计划任务。
|
||||
|
||||
```powershell
|
||||
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py sync-cron all
|
||||
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py enable <任务名>
|
||||
```
|
||||
|
||||
开关机制与 Linux 相同:禁用后目录移入 `.disabled/`,计划任务仍会触发,但任务会自动跳过。
|
||||
|
||||
---
|
||||
|
||||
## 开关机制
|
||||
|
||||
- **启用**:从 `.disabled/<task-id>/` 移回 `<task-id>/`
|
||||
- **关闭**:从 `<task-id>/` 移入 `.disabled/<task-id>/`
|
||||
- `run.py` 在入口检查 `task_is_disabled(ctx)`,若关闭直接 `return 0`,不写日志、不发通知、不获取锁
|
||||
- 禁用时 `cronctl run` 会直接跳过,不写日志、不获取锁
|
||||
- `/etc/cron.d/<task-id>` 可一直保留;`schedule.cron` 通过 `cronctl.py run <task-id>` 调度
|
||||
|
||||
---
|
||||
|
||||
## 多语言支持
|
||||
|
||||
调度入口统一为 `cronctl.py run`(Python)。业务脚本语言由 `task.json` 或入口文件决定:
|
||||
|
||||
| runtime | 入口 | 依赖 |
|
||||
|---|---|---|
|
||||
| `python`(默认) | `run.py` | Python 3 |
|
||||
| `javascript` | `run.js` | Node.js |
|
||||
| `bash` | `run.sh` | bash |
|
||||
| `powershell` | `run.ps1` | pwsh / powershell |
|
||||
|
||||
### task.json
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "python",
|
||||
"entry": "run.py"
|
||||
}
|
||||
```
|
||||
|
||||
无 `task.json` 时:有 `run.py` 视为 Python 任务(向后兼容)。
|
||||
|
||||
---
|
||||
|
||||
## 新建任务(模板)
|
||||
|
||||
**从 `_template/` 复制**。`_template` 本身是可管理的示例任务(每天 08:00 输出 hello world)。
|
||||
**默认从 `_template/`(Python)复制**;JavaScript / Bash / PowerShell 见 `_template-javascript` 等目录。
|
||||
|
||||
```bash
|
||||
TASK_ID="smallmengya-my-new-task"
|
||||
@@ -98,7 +124,6 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
|
||||
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
|
||||
|
||||
# 编辑 run.py 与 schedule.cron 后试跑
|
||||
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
|
||||
@@ -107,6 +132,12 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
|
||||
|
||||
详细约定见 `_template/README.md`;AI 代理见根目录 `AGENTS.md`。
|
||||
|
||||
### AI Agent 集成
|
||||
|
||||
- **Skill**:`.cursor/skills/sproutclaw-cron/` — Cursor Agent 自动识别定时任务操作规范
|
||||
- **MCP**:`.cursor/mcp.json` + `mcp-server/server.py` — 暴露 `cron_list_tasks`、`cron_run_task` 等工具
|
||||
- 首次使用 MCP:`pip install -r mcp-server/requirements.txt`,然后在 Cursor 设置中确认 MCP 服务器已启用
|
||||
|
||||
---
|
||||
|
||||
## 公共库 API
|
||||
@@ -204,9 +235,6 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
|
||||
每个 `run.py` 顶部都有 3 行 `sys.path.insert` 代码,是为了支持 `schedule.cron` 直接调用 `python3 run.py`。
|
||||
若将 `schedule.cron` 改为调用 `cronctl run <task-id>`,则 `run.py` 里的路径注入可以全部删掉。
|
||||
|
||||
### 2. `switch.sh` 冗余
|
||||
每个任务目录都有一份 `switch.sh`,其内部已经是代理调用 `cronctl.py`,新任务可以不再创建它,直接用 `cronctl enable/disable`。
|
||||
|
||||
---
|
||||
|
||||
## 任务配置文件格式(JSON)
|
||||
@@ -246,7 +274,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
|
||||
|
||||
## 依赖说明
|
||||
|
||||
这套系统**只依赖 Python 3 标准库**(`fcntl`、`subprocess`、`pathlib` 等),无需安装任何 PyPI 包。
|
||||
这套系统**只依赖 Python 3 标准库**(`subprocess`、`pathlib` 等),无需安装任何 PyPI 包。文件锁在 Linux 用 `fcntl`,Windows 用 `msvcrt`;定时调度在 Linux 用 `/etc/cron.d/`,Windows 用任务计划程序(`schtasks`)。
|
||||
|
||||
通知功能依赖两个本地项目(非 PyPI 依赖,可缺失时降级):
|
||||
- `lark-notice-api`:飞书通知,路径 `/shumengya/project/python/lark-notice-api/`
|
||||
|
||||
22
_template-bash/README.md
Normal file
22
_template-bash/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Bash 定时任务模板
|
||||
|
||||
Bash hello world 示例。业务逻辑写在 `run.sh`;disable / 锁 / 日志由 cronctl 负责。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3(cronctl)
|
||||
- Bash(Linux 自带;Windows 需 Git Bash 或 WSL)
|
||||
|
||||
## 新建任务
|
||||
|
||||
```bash
|
||||
TASK_ID="smallmengya-my-bash-task"
|
||||
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
cp -a "$CRON_ROOT/_template-bash" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template-bash/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
chmod +x "$CRON_ROOT/$TASK_ID/run.sh"
|
||||
```
|
||||
|
||||
## 其他语言模板
|
||||
|
||||
见 `_template-javascript/README.md` 中的对照表。
|
||||
2
_template-bash/run.sh
Normal file
2
_template-bash/run.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "hello world"
|
||||
8
_template-bash/schedule.cron
Normal file
8
_template-bash/schedule.cron
Normal file
@@ -0,0 +1,8 @@
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root
|
||||
CRON_MAIL_ENABLED=1
|
||||
|
||||
# 每天 08:00 Bash hello world 示例
|
||||
# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-bash
|
||||
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-bash >/dev/null 2>&1
|
||||
8
_template-bash/task.json
Normal file
8
_template-bash/task.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"runtime": "bash",
|
||||
"entry": "run.sh",
|
||||
"tags": [
|
||||
"Bash",
|
||||
"模板"
|
||||
]
|
||||
}
|
||||
32
_template-javascript/README.md
Normal file
32
_template-javascript/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# JavaScript 定时任务模板
|
||||
|
||||
Node.js hello world 示例。调度仍由 `cronctl.py run` 统一管理(disable / 锁 / 日志)。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3(cronctl)
|
||||
- Node.js(`node` 在 PATH 中)
|
||||
|
||||
## 新建任务
|
||||
|
||||
```bash
|
||||
TASK_ID="smallmengya-my-js-task"
|
||||
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
cp -a "$CRON_ROOT/_template-javascript" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template-javascript/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
```
|
||||
|
||||
编辑 `run.js` 与 `schedule.cron` 后试跑:
|
||||
|
||||
```bash
|
||||
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
|
||||
```
|
||||
|
||||
## 其他语言模板
|
||||
|
||||
| 目录 | 语言 |
|
||||
|---|---|
|
||||
| `_template` | Python(默认) |
|
||||
| `_template-javascript` | JavaScript |
|
||||
| `_template-bash` | Bash |
|
||||
| `_template-powershell` | PowerShell |
|
||||
1
_template-javascript/run.js
Normal file
1
_template-javascript/run.js
Normal file
@@ -0,0 +1 @@
|
||||
console.log("hello world");
|
||||
8
_template-javascript/schedule.cron
Normal file
8
_template-javascript/schedule.cron
Normal file
@@ -0,0 +1,8 @@
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root
|
||||
CRON_MAIL_ENABLED=1
|
||||
|
||||
# 每天 08:00 JavaScript hello world 示例(Node.js)
|
||||
# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-javascript
|
||||
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-javascript >/dev/null 2>&1
|
||||
8
_template-javascript/task.json
Normal file
8
_template-javascript/task.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"runtime": "javascript",
|
||||
"entry": "run.js",
|
||||
"tags": [
|
||||
"JavaScript",
|
||||
"模板"
|
||||
]
|
||||
}
|
||||
27
_template-powershell/README.md
Normal file
27
_template-powershell/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# PowerShell 定时任务模板
|
||||
|
||||
PowerShell hello world 示例。业务逻辑写在 `run.ps1`;disable / 锁 / 日志由 cronctl 负责。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3(cronctl)
|
||||
- PowerShell(`pwsh` 或 Windows 自带 `powershell`)
|
||||
|
||||
## 新建任务
|
||||
|
||||
```bash
|
||||
TASK_ID="smallmengya-my-ps-task"
|
||||
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
cp -a "$CRON_ROOT/_template-powershell" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template-powershell/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
```
|
||||
|
||||
Windows 上可直接:
|
||||
|
||||
```powershell
|
||||
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py run _template-powershell
|
||||
```
|
||||
|
||||
## 其他语言模板
|
||||
|
||||
见 `_template-javascript/README.md` 中的对照表。
|
||||
2
_template-powershell/run.ps1
Normal file
2
_template-powershell/run.ps1
Normal file
@@ -0,0 +1,2 @@
|
||||
Write-Output "hello world"
|
||||
exit 0
|
||||
8
_template-powershell/schedule.cron
Normal file
8
_template-powershell/schedule.cron
Normal file
@@ -0,0 +1,8 @@
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root
|
||||
CRON_MAIL_ENABLED=1
|
||||
|
||||
# 每天 08:00 PowerShell hello world 示例
|
||||
# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-powershell
|
||||
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-powershell >/dev/null 2>&1
|
||||
8
_template-powershell/task.json
Normal file
8
_template-powershell/task.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"runtime": "powershell",
|
||||
"entry": "run.ps1",
|
||||
"tags": [
|
||||
"PowerShell",
|
||||
"模板"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
# 定时任务模板 / 示例任务
|
||||
# 定时任务模板 / 示例任务(Python)
|
||||
|
||||
`_template` 本身是可管理的真实任务:每天 08:00 输出 `hello world`。
|
||||
新建任务时复制本目录,改任务名与业务逻辑即可。
|
||||
`_template` 是默认可管理的 Python hello world 任务(每天 08:00)。
|
||||
新建 Python 任务时复制本目录,改任务名与业务逻辑即可。
|
||||
|
||||
## 多语言模板
|
||||
|
||||
| 目录 | 语言 | 入口 |
|
||||
|---|---|---|
|
||||
| `_template` | **Python(默认)** | `run.py` |
|
||||
| `_template-javascript` | JavaScript | `run.js` |
|
||||
| `_template-bash` | Bash | `run.sh` |
|
||||
| `_template-powershell` | PowerShell | `run.ps1` |
|
||||
|
||||
非 Python 任务只需编写入口脚本;disable / 锁 / 日志由 `cronctl run` 负责。
|
||||
|
||||
## 新建步骤
|
||||
|
||||
@@ -11,23 +22,22 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
|
||||
|
||||
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
|
||||
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
|
||||
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
|
||||
|
||||
# 编辑 run.py,替换 hello world 为实际业务逻辑
|
||||
# 编辑 schedule.cron,调整 cron 表达式与注释
|
||||
|
||||
python3 "$CRON_ROOT/cronctl.py" status "$TASK_ID"
|
||||
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" # 手动试跑
|
||||
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/cron.d/
|
||||
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
|
||||
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
|
||||
```
|
||||
|
||||
## 必须文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `run.py` | 任务入口,导出 `run(ctx) -> int` |
|
||||
| `schedule.cron` | 系统 cron 配置,cron 行须调用 `cronctl.py run <task-id>` |
|
||||
| `switch.sh` | 可选,代理 `cronctl` 开关 |
|
||||
| `task.json` | 可选;声明 `runtime` 与 `entry` |
|
||||
| `run.py` | Python 任务入口,导出 `run(ctx) -> int` |
|
||||
| `schedule.cron` | cron 配置,须调用 `cronctl.py run <task-id>` |
|
||||
|
||||
## run.py 约定
|
||||
|
||||
@@ -35,7 +45,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/c
|
||||
2. 使用 `task_logging(ctx)` 写日志到 `logs/<task-id>.log`
|
||||
3. 使用 `acquire_cron_lock(ctx.lock_file, log=log)` 防止并发重入
|
||||
4. 业务逻辑写在 `log("start")` 与 `log("end")` 之间
|
||||
5. 需要汇总通知时使用 `shumengya_cron.notify.send_task_summary`
|
||||
5. 需要汇总通知时使用 `notify.send_task_summary`
|
||||
|
||||
## schedule.cron 约定
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys as _sys, pathlib as _pl
|
||||
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
|
||||
del _sys, _pl
|
||||
|
||||
from shumengya_cron.runner import (
|
||||
from runner import (
|
||||
TaskContext,
|
||||
acquire_cron_lock,
|
||||
task_is_disabled,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
task_id="$(basename "$(dirname "$0")")"
|
||||
action="${1:-toggle}"
|
||||
shift || true
|
||||
|
||||
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"
|
||||
8
_template/task.json
Normal file
8
_template/task.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"runtime": "python",
|
||||
"entry": "run.py",
|
||||
"tags": [
|
||||
"Python",
|
||||
"模板"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||
|
||||
from shumengya_cron.manager import (
|
||||
from manager import (
|
||||
cron_root,
|
||||
get_context,
|
||||
iter_task_ids,
|
||||
@@ -18,7 +18,7 @@ from shumengya_cron.manager import (
|
||||
set_task_state,
|
||||
sync_cron_d,
|
||||
)
|
||||
from shumengya_cron.runner import task_enabled_text, task_is_enabled
|
||||
from runner import task_enabled_text, task_is_enabled
|
||||
|
||||
|
||||
CRON_ROOT = cron_root()
|
||||
@@ -81,7 +81,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
try:
|
||||
if action == "run":
|
||||
task_rc = run_task(task_id, CRON_ROOT)
|
||||
print(f"▶ {task_id} (exit={task_rc})")
|
||||
marker = ">" if sys.platform == "win32" else "▶"
|
||||
print(f"{marker} {task_id} (exit={task_rc})")
|
||||
if task_rc != 0:
|
||||
rc = task_rc
|
||||
continue
|
||||
|
||||
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()
|
||||
]
|
||||
41
mcp-server/README.md
Normal file
41
mcp-server/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# SproutClaw Cron MCP Server
|
||||
|
||||
通过 [Model Context Protocol](https://modelcontextprotocol.io/) 让 AI Agent 管理本项目的定时任务。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# 或
|
||||
pip install fastmcp
|
||||
```
|
||||
|
||||
## 在 Cursor 中使用
|
||||
|
||||
项目已包含 `.cursor/mcp.json`。打开本仓库后 Cursor 会自动加载 `sproutclaw-cron` MCP 服务器。
|
||||
|
||||
手动测试(stdio):
|
||||
|
||||
```bash
|
||||
python mcp-server/server.py
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `SPROUTCLAW_CRON_ROOT` | 定时任务项目根目录;默认自动推断为 `mcp-server/` 的上级目录 |
|
||||
|
||||
## 工具一览
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `cron_list_tasks` | 列出全部任务 |
|
||||
| `cron_get_task` | 任务详情 |
|
||||
| `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` | 开关 |
|
||||
| `cron_run_task` | 立即执行 |
|
||||
| `cron_get_log` | 读日志 |
|
||||
| `cron_update_schedule` | 改 cron 表达式 / 描述 / 标签 |
|
||||
| `cron_sync_cron` | 同步 cron.d / schtasks |
|
||||
| `cron_create_task` | 从模板创建新任务 |
|
||||
| `cron_list_templates` | 可用模板 |
|
||||
12
mcp-server/pyproject.toml
Normal file
12
mcp-server/pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "sproutclaw-cron-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for SproutClaw Cron task management"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastmcp>=3.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
1
mcp-server/requirements.txt
Normal file
1
mcp-server/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
fastmcp>=3.0
|
||||
228
mcp-server/server.py
Normal file
228
mcp-server/server.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SproutClaw Cron MCP 服务器 — 通过 stdio 暴露定时任务管理工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
MCP_DIR = Path(__file__).resolve().parent
|
||||
CRON_ROOT = Path(os.environ.get("SPROUTCLAW_CRON_ROOT", MCP_DIR.parent)).resolve()
|
||||
LIB_DIR = CRON_ROOT / "lib"
|
||||
|
||||
if str(LIB_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(LIB_DIR))
|
||||
|
||||
from manager import ( # noqa: E402
|
||||
build_task_info,
|
||||
get_context,
|
||||
iter_task_ids,
|
||||
list_tasks,
|
||||
read_log_tail,
|
||||
run_task,
|
||||
set_task_state,
|
||||
sync_cron_d,
|
||||
toggle_task,
|
||||
update_task_schedule,
|
||||
)
|
||||
from runner import task_is_enabled # noqa: E402
|
||||
|
||||
TEMPLATE_DIRS: dict[str, str] = {
|
||||
"python": "_template",
|
||||
"javascript": "_template-javascript",
|
||||
"bash": "_template-bash",
|
||||
"powershell": "_template-powershell",
|
||||
}
|
||||
|
||||
TASK_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||
DEFAULT_CRON_ROOTS = (
|
||||
"/shumengya/project/agent/sproutclaw-cron",
|
||||
str(CRON_ROOT),
|
||||
str(CRON_ROOT).replace("\\", "/"),
|
||||
)
|
||||
|
||||
mcp = FastMCP(
|
||||
name="sproutclaw-cron",
|
||||
instructions=(
|
||||
"SproutClaw 定时任务系统。先 cron_list_tasks 了解任务;"
|
||||
"新建任务用 cron_create_task(默认禁用);"
|
||||
"改调度用 cron_update_schedule;"
|
||||
"试跑用 cron_run_task 后 cron_get_log 查日志。"
|
||||
f" 项目根目录: {CRON_ROOT}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
return CRON_ROOT
|
||||
|
||||
|
||||
def _task_dict(task_id: str) -> dict[str, object]:
|
||||
return build_task_info(task_id, _root()).to_dict()
|
||||
|
||||
|
||||
def _json(data: object) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _rewrite_schedule_cron(schedule_file: Path, template_name: str, task_id: str) -> None:
|
||||
text = schedule_file.read_text(encoding="utf-8")
|
||||
text = text.replace(template_name, task_id)
|
||||
cron_root_posix = _root().as_posix()
|
||||
for old in DEFAULT_CRON_ROOTS:
|
||||
if old and old != cron_root_posix:
|
||||
text = text.replace(old, cron_root_posix)
|
||||
text = re.sub(
|
||||
r"(/usr/bin/python3\s+)\S+/cronctl\.py",
|
||||
rf"\g<1>{cron_root_posix}/cronctl.py",
|
||||
text,
|
||||
)
|
||||
schedule_file.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def cron_list_tasks() -> str:
|
||||
"""列出所有定时任务(含 .disabled/ 下已关闭的任务)及状态、调度、runtime。"""
|
||||
tasks = [info.to_dict() for info in list_tasks(_root())]
|
||||
return _json({"count": len(tasks), "tasks": tasks})
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def cron_get_task(task_id: str) -> str:
|
||||
"""获取单个任务的详细信息(状态、cron 表达式、描述、日志大小、标签等)。"""
|
||||
return _json(_task_dict(task_id))
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_enable_task(task_id: str) -> str:
|
||||
"""启用任务:移出 .disabled/ 并同步系统 cron / Windows 计划任务。"""
|
||||
info, message = set_task_state(task_id, True, _root())
|
||||
return _json({"task": info.to_dict(), "message": message})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_disable_task(task_id: str) -> str:
|
||||
"""禁用任务:移入 .disabled/(cron 条目可保留,到点自动跳过)。"""
|
||||
info, message = set_task_state(task_id, False, _root())
|
||||
return _json({"task": info.to_dict(), "message": message})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_toggle_task(task_id: str) -> str:
|
||||
"""切换任务启用/禁用状态。"""
|
||||
info, message = toggle_task(task_id, _root())
|
||||
return _json({"task": info.to_dict(), "message": message})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_run_task(task_id: str) -> str:
|
||||
"""立即执行一次任务(同步等待完成),返回 exit code。禁用的任务会直接跳过。"""
|
||||
exit_code = run_task(task_id, _root())
|
||||
return _json({"task_id": task_id, "exit_code": exit_code})
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def cron_get_log(task_id: str, lines: int = 200) -> str:
|
||||
"""读取任务日志末尾内容(默认 200 行,最多 2000)。"""
|
||||
lines = max(1, min(lines, 2000))
|
||||
content = read_log_tail(task_id, lines, _root())
|
||||
return _json({"task_id": task_id, "lines": lines, "content": content})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_update_schedule(
|
||||
task_id: str,
|
||||
schedule: str,
|
||||
description: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""更新 schedule.cron 中的 cron 五段表达式、描述注释与 task.json 标签,并同步 cron.d。"""
|
||||
info, message = update_task_schedule(
|
||||
task_id,
|
||||
description=description,
|
||||
schedule=schedule,
|
||||
tags=tags,
|
||||
root=_root(),
|
||||
sync=True,
|
||||
)
|
||||
return _json({"task": info.to_dict(), "message": message})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_sync_cron(task_id: str) -> str:
|
||||
"""将 schedule.cron 同步到 /etc/cron.d/(Linux)或 schtasks(Windows),不改开关状态。"""
|
||||
ctx = get_context(task_id, _root())
|
||||
message = sync_cron_d(ctx, task_is_enabled(ctx))
|
||||
return _json({"task_id": task_id, "message": message or "已同步"})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cron_create_task(
|
||||
task_id: str,
|
||||
runtime: Literal["python", "javascript", "bash", "powershell"] = "python",
|
||||
enable: bool = False,
|
||||
) -> str:
|
||||
"""从语言模板复制创建新任务。命名约定 <主机名>-<功能描述>。默认创建后保持禁用。"""
|
||||
if not TASK_ID_RE.match(task_id):
|
||||
raise ValueError("task_id 仅允许字母数字、点、连字符与下划线,且不能以特殊符号开头")
|
||||
|
||||
template_name = TEMPLATE_DIRS[runtime]
|
||||
template_dir = _root() / template_name
|
||||
if not template_dir.is_dir():
|
||||
raise FileNotFoundError(f"模板不存在: {template_name}")
|
||||
|
||||
if task_id in iter_task_ids(_root()):
|
||||
raise ValueError(f"任务已存在: {task_id}")
|
||||
|
||||
target = _root() / task_id
|
||||
shutil.copytree(template_dir, target)
|
||||
schedule_file = target / "schedule.cron"
|
||||
if schedule_file.is_file():
|
||||
_rewrite_schedule_cron(schedule_file, template_name, task_id)
|
||||
|
||||
if runtime == "bash":
|
||||
run_sh = target / "run.sh"
|
||||
if run_sh.is_file():
|
||||
run_sh.chmod(run_sh.stat().st_mode | 0o111)
|
||||
|
||||
message = "已创建"
|
||||
if not enable:
|
||||
info, msg = set_task_state(task_id, False, _root())
|
||||
message = msg or "已创建并禁用"
|
||||
return _json({"task": info.to_dict(), "message": message, "template": template_name})
|
||||
|
||||
info, msg = set_task_state(task_id, True, _root())
|
||||
return _json({"task": info.to_dict(), "message": msg or message, "template": template_name})
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def cron_list_templates() -> str:
|
||||
"""列出可用于 cron_create_task 的语言模板。"""
|
||||
templates = []
|
||||
for runtime, dirname in TEMPLATE_DIRS.items():
|
||||
path = _root() / dirname
|
||||
templates.append(
|
||||
{
|
||||
"runtime": runtime,
|
||||
"template_dir": dirname,
|
||||
"exists": path.is_dir(),
|
||||
"entry": {
|
||||
"python": "run.py",
|
||||
"javascript": "run.js",
|
||||
"bash": "run.sh",
|
||||
"powershell": "run.ps1",
|
||||
}[runtime],
|
||||
}
|
||||
)
|
||||
return _json({"templates": templates})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -1,21 +0,0 @@
|
||||
[
|
||||
{"repo": "shumengya/ai-translate", "local": "/shumengya/project/cloudflare/ai-translate"},
|
||||
{"repo": "shumengya/cf-doh", "local": "/shumengya/project/cloudflare/cf-doh"},
|
||||
{"repo": "shumengya/cf-favicon", "local": "/shumengya/project/cloudflare/cf-favicon"},
|
||||
{"repo": "shumengya/cf-ip-geo", "local": "/shumengya/project/cloudflare/cf-ip-geo"},
|
||||
{"repo": "shumengya/mengya-nav", "local": "/shumengya/project/cloudflare/mengya-nav"},
|
||||
{"repo": "shumengya/InfoGenie", "local": "/shumengya/project/frontend-backend/InfoGenie"},
|
||||
{"repo": "shumengya/mengyaconnect", "local": "/shumengya/project/frontend-backend/mengyaconnect"},
|
||||
{"repo": "shumengya/mengyadriftbottle", "local": "/shumengya/project/frontend-backend/mengyadriftbottle"},
|
||||
{"repo": "shumengya/mengyakeyvault", "local": "/shumengya/project/frontend-backend/mengyakeyvault"},
|
||||
{"repo": "shumengya/mengyalinkfly", "local": "/shumengya/project/frontend-backend/mengyalinkfly"},
|
||||
{"repo": "shumengya/mengyamonitor", "local": "/shumengya/project/frontend-backend/mengyamonitor"},
|
||||
{"repo": "shumengya/mengyanote", "local": "/shumengya/project/frontend-backend/mengyanote"},
|
||||
{"repo": "shumengya/mengpost", "local": "/shumengya/project/frontend-backend/mengpost"},
|
||||
{"repo": "shumengya/mengyaping", "local": "/shumengya/project/frontend-backend/mengyaping"},
|
||||
{"repo": "shumengya/mengyaprofile", "local": "/shumengya/project/frontend-backend/mengyaprofile"},
|
||||
{"repo": "shumengya/mengyastore", "local": "/shumengya/project/frontend-backend/mengyastore"},
|
||||
{"repo": "shumengya/SproutGate", "local": "/shumengya/project/frontend-backend/SproutGate"},
|
||||
{"repo": "shumengya/SmyWorkCollect", "local": "/shumengya/project/frontend-backend/SproutWorkCollect"},
|
||||
{"repo": "shumengya/random-background-api","local": "/shumengya/project/frontend/random-background-api"}
|
||||
]
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
Gitea 仓库同步:配置见本目录 repos.json。
|
||||
|
||||
同步逻辑:先 fetch 检查远端是否有新提交,有则 fast-forward merge,无则跳过,
|
||||
不会无谓拉取;仓库依次串行处理,每条间隔 SYNC_DELAY_SECONDS 秒。
|
||||
|
||||
env 变量:
|
||||
BASE_DIR (已废弃,local 字段现在必填)
|
||||
SYNC_DELAY_SECONDS 每个仓库处理间隔秒数(默认 2)
|
||||
GIT_SSH_COMMAND git SSH 参数
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys as _sys, pathlib as _pl
|
||||
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
|
||||
del _sys, _pl
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from shumengya_cron.notify import TaskResult, send_task_summary
|
||||
from shumengya_cron.runner import (
|
||||
TaskContext,
|
||||
acquire_cron_lock,
|
||||
append_raw_to_log,
|
||||
task_is_disabled,
|
||||
task_logging,
|
||||
)
|
||||
|
||||
|
||||
def _load_repos(path: Path) -> list[tuple[str, Path]]:
|
||||
"""加载 repos.json,local 字段必填,缺失的条目直接跳过。"""
|
||||
if not path.is_file():
|
||||
return []
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
result: list[tuple[str, Path]] = []
|
||||
for item in data:
|
||||
owner_repo = item.get("repo", "").strip()
|
||||
local_s = item.get("local", "").strip()
|
||||
if not owner_repo or not local_s:
|
||||
continue
|
||||
if "/" not in owner_repo:
|
||||
owner_repo = f"shumengya/{owner_repo}"
|
||||
result.append((owner_repo, Path(local_s)))
|
||||
return result
|
||||
|
||||
|
||||
def _git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str]:
|
||||
r = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
|
||||
|
||||
|
||||
def run(ctx: TaskContext) -> int:
|
||||
if task_is_disabled(ctx):
|
||||
return 0
|
||||
|
||||
os.environ.setdefault("HOME", "/root")
|
||||
os.environ.setdefault(
|
||||
"GIT_SSH_COMMAND",
|
||||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
|
||||
)
|
||||
|
||||
repos_file = ctx.task_dir / "repos.json"
|
||||
sync_delay = int(os.environ.get("SYNC_DELAY_SECONDS", "2"))
|
||||
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
result = TaskResult()
|
||||
|
||||
def sync_one(owner_repo: str, dir_path: Path, log) -> None:
|
||||
parts = owner_repo.split("/")
|
||||
owner, repo = parts[0], parts[-1]
|
||||
remote_url = f"ssh://git@git.shumengya.top:8022/{owner}/{repo}.git"
|
||||
|
||||
# ── 仓库不存在:直接 clone ───────────────────────────
|
||||
if not (dir_path / ".git").is_dir():
|
||||
if dir_path.exists():
|
||||
log(f"[{owner_repo}] 路径已存在但不是 git 仓库,跳过:{dir_path}")
|
||||
result.add_skip(f"{owner_repo}:not_git")
|
||||
return
|
||||
dir_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log(f"[{owner_repo}] clone -> {dir_path}")
|
||||
rc, out = _git(["clone", remote_url, str(dir_path)])
|
||||
if out:
|
||||
append_raw_to_log(ctx.log_file, out)
|
||||
if rc == 0:
|
||||
log(f"[{owner_repo}] clone OK")
|
||||
result.add_ok(f"{owner_repo}:clone")
|
||||
else:
|
||||
log(f"[{owner_repo}] clone 失败,请检查 SSH/权限/网络。")
|
||||
result.add_fail(f"{owner_repo}:clone_failed")
|
||||
return
|
||||
|
||||
# ── 仓库已存在:fetch → 检查差异 → merge ─────────────
|
||||
|
||||
# 确认当前分支
|
||||
rc, branch = _git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=dir_path)
|
||||
branch = branch.splitlines()[0].strip() if branch else ""
|
||||
if rc != 0 or not branch:
|
||||
log(f"[{owner_repo}] detached HEAD,跳过。")
|
||||
result.add_skip(f"{owner_repo}:detached")
|
||||
return
|
||||
|
||||
# 找远端名并更新 URL
|
||||
rc, remote_name = _git(["config", "--get", f"branch.{branch}.remote"], cwd=dir_path)
|
||||
remote_name = remote_name.splitlines()[0].strip() if remote_name else ""
|
||||
if not remote_name:
|
||||
rc2, rn = _git(["remote"], cwd=dir_path)
|
||||
remote_name = rn.splitlines()[0].strip() if rc2 == 0 and rn else ""
|
||||
if not remote_name:
|
||||
log(f"[{owner_repo}] 未找到远端名,跳过。")
|
||||
result.add_fail(f"{owner_repo}:no_remote")
|
||||
return
|
||||
|
||||
_git(["remote", "set-url", remote_name, remote_url], cwd=dir_path)
|
||||
|
||||
# 工作区必须干净
|
||||
rc, dirty = _git(["status", "--porcelain"], cwd=dir_path)
|
||||
if dirty:
|
||||
log(f"[{owner_repo}] 工作区有改动,跳过(避免覆盖本地改动)。")
|
||||
result.add_skip(f"{owner_repo}:dirty")
|
||||
return
|
||||
|
||||
# fetch 远端最新
|
||||
rc, fetch_out = _git(["fetch", "--prune", remote_name, branch], cwd=dir_path)
|
||||
if fetch_out:
|
||||
append_raw_to_log(ctx.log_file, fetch_out)
|
||||
if rc != 0:
|
||||
log(f"[{owner_repo}] fetch 失败,跳过。")
|
||||
result.add_fail(f"{owner_repo}:fetch_failed")
|
||||
return
|
||||
|
||||
# 检查是否有新提交
|
||||
rc, count = _git(["rev-list", "HEAD..FETCH_HEAD", "--count"], cwd=dir_path)
|
||||
if rc == 0 and count.strip() == "0":
|
||||
log(f"[{owner_repo}] 已是最新,跳过。")
|
||||
result.add_skip(f"{owner_repo}:already_latest")
|
||||
return
|
||||
|
||||
# 有更新,fast-forward merge
|
||||
log(f"[{owner_repo}] 发现 {count.strip()} 个新提交,合并中…")
|
||||
rc, merge_out = _git(["merge", "--ff-only", "FETCH_HEAD"], cwd=dir_path)
|
||||
if merge_out:
|
||||
append_raw_to_log(ctx.log_file, merge_out)
|
||||
if rc == 0:
|
||||
log(f"[{owner_repo}] OK")
|
||||
result.add_ok(f"{owner_repo}:updated")
|
||||
else:
|
||||
log(f"[{owner_repo}] merge 失败,请手动检查:git -C {dir_path} merge --ff-only FETCH_HEAD")
|
||||
result.add_fail(f"{owner_repo}:merge_failed")
|
||||
|
||||
with task_logging(ctx) as log:
|
||||
with acquire_cron_lock(
|
||||
ctx.lock_file,
|
||||
busy_message=os.environ.get("CRON_LOCK_BUSY_MSG", "已有同步任务在运行,跳过本次。"),
|
||||
log=log,
|
||||
) as locked:
|
||||
if not locked:
|
||||
return 0
|
||||
|
||||
log("start")
|
||||
log(f"repos_file={repos_file}")
|
||||
log(f"sync_delay_seconds={sync_delay}")
|
||||
|
||||
if not repos_file.is_file():
|
||||
log(f"仓库列表文件不存在:{repos_file}")
|
||||
result.add_fail("repos_file:missing")
|
||||
send_task_summary(ctx, result, start_time, log=log)
|
||||
return 1
|
||||
|
||||
repo_items = _load_repos(repos_file)
|
||||
log(f"共 {len(repo_items)} 个仓库,依次处理")
|
||||
|
||||
for idx, (owner_repo, dir_path) in enumerate(repo_items):
|
||||
sync_one(owner_repo, dir_path, log)
|
||||
if sync_delay > 0 and idx < len(repo_items) - 1:
|
||||
time.sleep(sync_delay)
|
||||
|
||||
log("end")
|
||||
send_task_summary(ctx, result, start_time, log=log)
|
||||
|
||||
return 0 if result.success else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import pathlib
|
||||
task_id = pathlib.Path(__file__).parent.name
|
||||
ctx = TaskContext.from_task_id(task_id)
|
||||
return run(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,10 +0,0 @@
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root
|
||||
CRON_MAIL_ENABLED=1
|
||||
|
||||
# 每天 00:00 按 repos.json 同步 Gitea 仓库到本地,仅在有新提交时 fast-forward 合并
|
||||
# 将本文件复制到 /etc/cron.d/smallmengya-gitea-repo-sync 即可生效
|
||||
# 开关:bash /shumengya/project/agent/sproutclaw-cron/smallmengya-gitea-repo-sync/switch.sh on|off|toggle
|
||||
# 统一管理:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status|enable|disable|toggle smallmengya-gitea-repo-sync|all
|
||||
0 0 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run smallmengya-gitea-repo-sync >/dev/null 2>&1
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
task_id="$(basename "$(dirname "$0")")"
|
||||
action="${1:-toggle}"
|
||||
shift || true
|
||||
|
||||
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"
|
||||
32
start-webui.bat
Normal file
32
start-webui.bat
Normal file
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "BACKEND=%~dp0webui\backend"
|
||||
set "VENV=%BACKEND%\.venv"
|
||||
set "HOST=%SPROUTCLAW_CRON_WEB_HOST%"
|
||||
set "PORT=%SPROUTCLAW_CRON_WEB_PORT%"
|
||||
|
||||
if not defined HOST set "HOST=0.0.0.0"
|
||||
if not defined PORT set "PORT=8765"
|
||||
|
||||
if not exist "%VENV%\Scripts\python.exe" (
|
||||
echo [webui] creating venv...
|
||||
python -m venv "%VENV%"
|
||||
if errorlevel 1 (
|
||||
echo [webui] venv failed. Install Python and add it to PATH.
|
||||
exit /b 1
|
||||
)
|
||||
echo [webui] installing deps...
|
||||
"%VENV%\Scripts\python.exe" -m pip install -r "%BACKEND%\requirements.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
set "URL=http://127.0.0.1:%PORT%/"
|
||||
echo [webui] starting: %URL%
|
||||
start "" "%URL%"
|
||||
|
||||
"%VENV%\Scripts\uvicorn.exe" main:app --app-dir "%BACKEND%" --host %HOST% --port %PORT% --reload --reload-dir "%BACKEND%" --reload-dir "%~dp0lib"
|
||||
|
||||
endlocal
|
||||
@@ -17,16 +17,17 @@ FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
sys.path.insert(0, str(CRON_ROOT / "lib"))
|
||||
|
||||
from shumengya_cron.manager import ( # noqa: E402
|
||||
from manager import ( # noqa: E402
|
||||
build_task_info,
|
||||
list_tasks,
|
||||
read_log_tail,
|
||||
set_task_state,
|
||||
sync_cron_d,
|
||||
toggle_task,
|
||||
update_task_schedule,
|
||||
)
|
||||
from shumengya_cron.manager import get_context # noqa: E402
|
||||
from shumengya_cron.runner import task_is_enabled # noqa: E402
|
||||
from manager import get_context # noqa: E402
|
||||
from runner import task_is_enabled # noqa: E402
|
||||
|
||||
app = FastAPI(title="SproutClaw Cron", version="1.0.0")
|
||||
|
||||
@@ -45,6 +46,18 @@ class RunResponse(BaseModel):
|
||||
pid: int | None = None
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
description: str = ""
|
||||
schedule: str
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class ScheduleUpdateResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
task: dict[str, object]
|
||||
|
||||
|
||||
def _task_not_found(exc: FileNotFoundError) -> HTTPException:
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
@@ -62,6 +75,13 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/runtimes")
|
||||
def api_runtimes() -> list[dict[str, object]]:
|
||||
from runtime_probe import probe_all_runtimes
|
||||
|
||||
return probe_all_runtimes()
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def api_list_tasks() -> list[dict[str, object]]:
|
||||
return [task.to_dict() for task in list_tasks(CRON_ROOT)]
|
||||
@@ -112,6 +132,27 @@ def api_sync_cron(task_id: str) -> MessageResponse:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.patch("/api/tasks/{task_id}/schedule", response_model=ScheduleUpdateResponse)
|
||||
def api_update_schedule(task_id: str, body: ScheduleUpdate) -> ScheduleUpdateResponse:
|
||||
try:
|
||||
info, message = update_task_schedule(
|
||||
task_id,
|
||||
description=body.description,
|
||||
schedule=body.schedule,
|
||||
tags=body.tags,
|
||||
root=CRON_ROOT,
|
||||
sync=True,
|
||||
)
|
||||
return ScheduleUpdateResponse(
|
||||
message=message or "已保存",
|
||||
task=info.to_dict(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run", response_model=RunResponse)
|
||||
def api_run_task(task_id: str) -> RunResponse:
|
||||
try:
|
||||
|
||||
@@ -2,6 +2,7 @@ const API = "/api";
|
||||
const REFRESH_INTERVAL = 15000;
|
||||
|
||||
const els = {
|
||||
runtimes: document.getElementById("runtimes"),
|
||||
taskBody: document.getElementById("task-body"),
|
||||
taskCards: document.getElementById("task-cards"),
|
||||
stats: document.getElementById("stats"),
|
||||
@@ -13,12 +14,25 @@ const els = {
|
||||
logTitle: document.getElementById("log-title"),
|
||||
logSubtitle: document.getElementById("log-subtitle"),
|
||||
logContent: document.getElementById("log-content"),
|
||||
sidebar: document.getElementById("sidebar"),
|
||||
overlay: document.getElementById("overlay"),
|
||||
editDialog: document.getElementById("edit-dialog"),
|
||||
editForm: document.getElementById("edit-form"),
|
||||
editTitle: document.getElementById("edit-title"),
|
||||
editDescription: document.getElementById("edit-description"),
|
||||
editMinute: document.getElementById("edit-minute"),
|
||||
editHour: document.getElementById("edit-hour"),
|
||||
editDom: document.getElementById("edit-dom"),
|
||||
editMonth: document.getElementById("edit-month"),
|
||||
editDow: document.getElementById("edit-dow"),
|
||||
editPreview: document.getElementById("edit-preview"),
|
||||
cronPresets: document.getElementById("cron-presets"),
|
||||
editTagListEl: document.getElementById("edit-tags"),
|
||||
editTagInput: document.getElementById("edit-tag-input"),
|
||||
};
|
||||
|
||||
let allTasks = [];
|
||||
let currentLogTaskId = null;
|
||||
let currentEditTaskId = null;
|
||||
let editTagList = [];
|
||||
let refreshTimer = null;
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
@@ -61,19 +75,23 @@ async function api(path, options = {}) {
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || data.message || `请求失败 (${res.status})`);
|
||||
const detail = data.detail;
|
||||
const msg = Array.isArray(detail)
|
||||
? detail.map((d) => d.msg || d).join("; ")
|
||||
: detail || data.message || `请求失败 (${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function statusMarkup(task) {
|
||||
if (task.running) {
|
||||
return '<span class="status run"><span class="dot"></span>运行中</span>';
|
||||
return '<span class="badge badge-run"><span class="dot"></span>运行中</span>';
|
||||
}
|
||||
if (task.enabled) {
|
||||
return '<span class="status on"><span class="dot"></span>已开启</span>';
|
||||
return '<span class="badge badge-on"><span class="dot"></span>已开启</span>';
|
||||
}
|
||||
return '<span class="status off"><span class="dot"></span>已关闭</span>';
|
||||
return '<span class="badge badge-off"><span class="dot"></span>已关闭</span>';
|
||||
}
|
||||
|
||||
function scheduleMarkup(task) {
|
||||
@@ -97,39 +115,173 @@ function actionButtons(task) {
|
||||
<div class="actions">
|
||||
<button type="button" class="btn ${toggleClass}" data-action="toggle" data-id="${escapeHtml(task.task_id)}">${toggleLabel}</button>
|
||||
<button type="button" class="btn" data-action="run" data-id="${escapeHtml(task.task_id)}" ${task.running ? "disabled" : ""}>运行</button>
|
||||
<button type="button" class="btn" data-action="edit" data-id="${escapeHtml(task.task_id)}">修改</button>
|
||||
<button type="button" class="btn" data-action="sync" data-id="${escapeHtml(task.task_id)}">同步</button>
|
||||
<button type="button" class="btn" data-action="log" data-id="${escapeHtml(task.task_id)}">日志</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const CRON_FIELD_KEYS = ["editMinute", "editHour", "editDom"];
|
||||
|
||||
function parseCron(expr) {
|
||||
const parts = (expr || "0 8 * * *").trim().split(/\s+/);
|
||||
return {
|
||||
minute: parts[0] ?? "0",
|
||||
hour: parts[1] ?? "8",
|
||||
dom: parts[2] ?? "*",
|
||||
month: parts[3] ?? "*",
|
||||
dow: parts[4] ?? "*",
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_DOW_OPTIONS = [
|
||||
["*", "每"],
|
||||
["0", "周日"],
|
||||
["1", "周一"],
|
||||
["2", "周二"],
|
||||
["3", "周三"],
|
||||
["4", "周四"],
|
||||
["5", "周五"],
|
||||
["6", "周六"],
|
||||
];
|
||||
|
||||
const CRON_MONTH_OPTIONS = [
|
||||
["*", "每"],
|
||||
...Array.from({ length: 12 }, (_, i) => [String(i + 1), `${i + 1}月`]),
|
||||
];
|
||||
|
||||
function resetSelectOptions(selectEl, options, value) {
|
||||
selectEl.innerHTML = options.map(
|
||||
([val, label]) => `<option value="${val}">${label}</option>`
|
||||
).join("");
|
||||
if ([...selectEl.options].some((opt) => opt.value === value)) {
|
||||
selectEl.value = value;
|
||||
return;
|
||||
}
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = value;
|
||||
opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
function resetDowSelect(value) {
|
||||
resetSelectOptions(els.editDow, CRON_DOW_OPTIONS, value);
|
||||
}
|
||||
|
||||
function resetMonthSelect(value) {
|
||||
resetSelectOptions(els.editMonth, CRON_MONTH_OPTIONS, value);
|
||||
}
|
||||
|
||||
function fillCronForm(expr) {
|
||||
const { minute, hour, dom, month, dow } = parseCron(expr);
|
||||
els.editMinute.value = minute;
|
||||
els.editHour.value = hour;
|
||||
els.editDom.value = dom;
|
||||
resetMonthSelect(month);
|
||||
resetDowSelect(dow);
|
||||
}
|
||||
|
||||
function buildScheduleFromForm() {
|
||||
const values = CRON_FIELD_KEYS.map((key) => {
|
||||
const raw = els[key].value.trim();
|
||||
return raw === "" ? "*" : raw;
|
||||
});
|
||||
values.push(els.editMonth.value.trim() || "*");
|
||||
values.push(els.editDow.value.trim() || "*");
|
||||
return values.join(" ");
|
||||
}
|
||||
|
||||
function updateEditPreview() {
|
||||
try {
|
||||
els.editPreview.textContent = buildScheduleFromForm() || "—";
|
||||
} catch {
|
||||
els.editPreview.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TASK_TAGS = 4;
|
||||
|
||||
const RUNTIME_LABELS = {
|
||||
python: "Python",
|
||||
javascript: "JavaScript",
|
||||
bash: "Bash",
|
||||
powershell: "PowerShell",
|
||||
};
|
||||
|
||||
function runtimeLabel(task) {
|
||||
return RUNTIME_LABELS[task.runtime] || task.runtime || "Python";
|
||||
}
|
||||
|
||||
function displayTags(task) {
|
||||
const tags = Array.isArray(task.tags) ? task.tags.filter(Boolean) : [];
|
||||
if (tags.length) return tags.slice(0, MAX_TASK_TAGS);
|
||||
return [runtimeLabel(task)];
|
||||
}
|
||||
|
||||
function tagsMarkup(task) {
|
||||
return displayTags(task)
|
||||
.map((tag) => `<span class="task-tag">${escapeHtml(tag)}</span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function nameMarkup(task) {
|
||||
return `
|
||||
<div class="task-name">${escapeHtml(task.task_id)}</div>
|
||||
<div class="task-name-row">
|
||||
<span class="task-name">${escapeHtml(task.task_id)}</span>
|
||||
${tagsMarkup(task)}
|
||||
</div>
|
||||
${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRuntimes(items) {
|
||||
els.runtimes.innerHTML = items
|
||||
.map((item) => {
|
||||
const version = item.available
|
||||
? escapeHtml(item.version || "—")
|
||||
: '<span class="runtime-missing">无</span>';
|
||||
const stateClass = item.available ? "is-ok" : "is-missing";
|
||||
return `
|
||||
<div class="runtime-card ${stateClass}">
|
||||
<span class="runtime-name">${escapeHtml(item.name)}</span>
|
||||
<span class="runtime-version">${version}</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadRuntimes() {
|
||||
try {
|
||||
const items = await api("/runtimes");
|
||||
renderRuntimes(items);
|
||||
} catch {
|
||||
els.runtimes.innerHTML = '<div class="runtime-card is-missing"><span class="runtime-name">运行时</span><span class="runtime-version"><span class="runtime-missing">检测失败</span></span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(tasks) {
|
||||
const enabled = tasks.filter((t) => t.enabled).length;
|
||||
const running = tasks.filter((t) => t.running).length;
|
||||
const off = tasks.length - enabled;
|
||||
els.stats.innerHTML = `
|
||||
<div class="meta-item">
|
||||
<span class="label">任务总数</span>
|
||||
<span class="value">${tasks.length}</span>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">任务总数</span>
|
||||
<span class="stat-value">${tasks.length}</span>
|
||||
</div>
|
||||
<div class="meta-item is-accent">
|
||||
<span class="label">已开启</span>
|
||||
<span class="value">${enabled}</span>
|
||||
<div class="stat-card stat-accent">
|
||||
<span class="stat-label">已开启</span>
|
||||
<span class="stat-value">${enabled}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">已关闭</span>
|
||||
<span class="value">${off}</span>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">已关闭</span>
|
||||
<span class="stat-value">${off}</span>
|
||||
</div>
|
||||
<div class="meta-item is-run">
|
||||
<span class="label">运行中</span>
|
||||
<span class="value">${running}</span>
|
||||
<div class="stat-card stat-run">
|
||||
<span class="stat-label">运行中</span>
|
||||
<span class="stat-value">${running}</span>
|
||||
</div>
|
||||
`;
|
||||
els.summaryText.textContent = running
|
||||
@@ -167,18 +319,18 @@ function renderTasks(tasks) {
|
||||
<div class="name">${nameMarkup(task)}</div>
|
||||
${statusMarkup(task)}
|
||||
</div>
|
||||
<div class="task-card-body">
|
||||
<div>
|
||||
<div class="task-card-meta">
|
||||
<div class="meta-block">
|
||||
<span class="field-label">调度</span>
|
||||
${scheduleMarkup(task)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-block">
|
||||
<span class="field-label">日志</span>
|
||||
<span class="log-meta">${formatBytes(task.log_size)}</span>
|
||||
<span class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</span>
|
||||
<div class="log-meta">${formatBytes(task.log_size)}</div>
|
||||
<div class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</div>
|
||||
</div>
|
||||
</div>
|
||||
${actionButtons(task)}
|
||||
<div class="task-card-actions">${actionButtons(task)}</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
@@ -188,10 +340,14 @@ function renderTasks(tasks) {
|
||||
function applyFilter() {
|
||||
const q = els.filter.value.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? allTasks.filter((t) =>
|
||||
t.task_id.toLowerCase().includes(q) ||
|
||||
(t.description || "").toLowerCase().includes(q)
|
||||
)
|
||||
? allTasks.filter((t) => {
|
||||
const tagText = displayTags(t).join(" ").toLowerCase();
|
||||
return (
|
||||
t.task_id.toLowerCase().includes(q) ||
|
||||
(t.description || "").toLowerCase().includes(q) ||
|
||||
tagText.includes(q)
|
||||
);
|
||||
})
|
||||
: allTasks;
|
||||
renderTasks(filtered);
|
||||
}
|
||||
@@ -203,6 +359,7 @@ async function loadTasks() {
|
||||
renderStats(tasks);
|
||||
applyFilter();
|
||||
els.lastRefresh.textContent = `已刷新 ${nowText()}`;
|
||||
await loadRuntimes();
|
||||
} catch (err) {
|
||||
els.taskBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(err.message)}</td></tr>`;
|
||||
els.taskCards.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
|
||||
@@ -217,6 +374,10 @@ async function handleAction(action, taskId) {
|
||||
await openLog(taskId);
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
openEdit(taskId);
|
||||
return;
|
||||
}
|
||||
if (action === "run") {
|
||||
const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" });
|
||||
showToast(res.ok === false ? res.message : `已启动 ${taskId}`);
|
||||
@@ -256,6 +417,95 @@ async function refreshLog() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderEditTags() {
|
||||
if (!els.editTagListEl) return;
|
||||
els.editTagListEl.innerHTML = editTagList
|
||||
.map(
|
||||
(tag, index) => `
|
||||
<span class="task-tag is-editable">
|
||||
${escapeHtml(tag)}
|
||||
<button type="button" class="tag-remove" data-index="${index}" aria-label="移除 ${escapeHtml(tag)}">×</button>
|
||||
</span>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
const atLimit = editTagList.length >= MAX_TASK_TAGS;
|
||||
if (els.editTagInput) els.editTagInput.disabled = atLimit;
|
||||
const addBtn = document.getElementById("btn-tag-add");
|
||||
if (addBtn) addBtn.disabled = atLimit;
|
||||
}
|
||||
|
||||
function addEditTag() {
|
||||
if (!els.editTagInput) return;
|
||||
const value = els.editTagInput.value.trim();
|
||||
if (!value) return;
|
||||
if (editTagList.length >= MAX_TASK_TAGS) {
|
||||
showToast(`最多 ${MAX_TASK_TAGS} 个标签`, true);
|
||||
return;
|
||||
}
|
||||
if (editTagList.includes(value)) {
|
||||
showToast("标签已存在", true);
|
||||
return;
|
||||
}
|
||||
editTagList.push(value);
|
||||
els.editTagInput.value = "";
|
||||
renderEditTags();
|
||||
}
|
||||
|
||||
function removeEditTag(index) {
|
||||
if (index < 0 || index >= editTagList.length) return;
|
||||
editTagList.splice(index, 1);
|
||||
renderEditTags();
|
||||
}
|
||||
|
||||
function openEdit(taskId) {
|
||||
const task = allTasks.find((t) => t.task_id === taskId);
|
||||
if (!task) {
|
||||
showToast("任务不存在", true);
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditTaskId = taskId;
|
||||
els.editTitle.textContent = taskId;
|
||||
els.editDescription.value = task.description || "";
|
||||
editTagList = [...displayTags(task)];
|
||||
if (els.editTagInput) els.editTagInput.value = "";
|
||||
renderEditTags();
|
||||
fillCronForm(task.schedule || "0 8 * * *");
|
||||
updateEditPreview();
|
||||
els.editDialog.showModal();
|
||||
}
|
||||
|
||||
async function saveEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!currentEditTaskId) return;
|
||||
|
||||
const schedule = buildScheduleFromForm();
|
||||
if (!schedule || schedule.split(/\s+/).length !== 5) {
|
||||
showToast("请填写完整的分、时、日、月、周", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
els.btnEditSave.disabled = true;
|
||||
const res = await api(`/tasks/${encodeURIComponent(currentEditTaskId)}/schedule`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
description: els.editDescription.value.trim(),
|
||||
schedule,
|
||||
tags: editTagList,
|
||||
}),
|
||||
});
|
||||
els.editDialog.close();
|
||||
showToast(res.message || "已保存");
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
showToast(err.message, true);
|
||||
} finally {
|
||||
els.btnEditSave.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindActions(root) {
|
||||
root.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-action]");
|
||||
@@ -264,31 +514,53 @@ function bindActions(root) {
|
||||
});
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
els.sidebar.classList.remove("open");
|
||||
els.overlay.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("btn-refresh").addEventListener("click", loadTasks);
|
||||
document.getElementById("btn-refresh-top").addEventListener("click", loadTasks);
|
||||
document.getElementById("btn-log-refresh").addEventListener("click", refreshLog);
|
||||
document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close());
|
||||
els.filter.addEventListener("input", applyFilter);
|
||||
|
||||
document.getElementById("menu-toggle").addEventListener("click", () => {
|
||||
els.sidebar.classList.add("open");
|
||||
els.overlay.hidden = false;
|
||||
document.getElementById("btn-edit-close").addEventListener("click", () => els.editDialog.close());
|
||||
document.getElementById("btn-edit-cancel").addEventListener("click", () => els.editDialog.close());
|
||||
els.editForm.addEventListener("submit", saveEdit);
|
||||
els.editForm.addEventListener("click", (event) => {
|
||||
if (event.target.closest("#btn-tag-add")) {
|
||||
event.preventDefault();
|
||||
addEditTag();
|
||||
return;
|
||||
}
|
||||
const removeBtn = event.target.closest(".tag-remove");
|
||||
if (removeBtn) {
|
||||
event.preventDefault();
|
||||
removeEditTag(Number(removeBtn.dataset.index));
|
||||
}
|
||||
});
|
||||
els.overlay.addEventListener("click", closeSidebar);
|
||||
els.editForm.addEventListener("keydown", (event) => {
|
||||
if (event.target !== els.editTagInput || event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
addEditTag();
|
||||
});
|
||||
CRON_FIELD_KEYS.forEach((key) => els[key].addEventListener("input", updateEditPreview));
|
||||
els.editMonth.addEventListener("change", updateEditPreview);
|
||||
els.editDow.addEventListener("change", updateEditPreview);
|
||||
els.cronPresets.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-preset]");
|
||||
if (!btn) return;
|
||||
fillCronForm(btn.dataset.preset);
|
||||
updateEditPreview();
|
||||
});
|
||||
els.btnEditSave = document.getElementById("btn-edit-save");
|
||||
els.filter.addEventListener("input", applyFilter);
|
||||
|
||||
els.logDialog.addEventListener("click", (event) => {
|
||||
if (event.target === els.logDialog) els.logDialog.close();
|
||||
});
|
||||
els.editDialog.addEventListener("click", (event) => {
|
||||
if (event.target === els.editDialog) els.editDialog.close();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (els.logDialog.open) els.logDialog.close();
|
||||
else if (els.sidebar.classList.contains("open")) closeSidebar();
|
||||
else if (els.editDialog.open) els.editDialog.close();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -8,54 +8,34 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar" id="sidebar" aria-label="主导航">
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>SproutClaw Cron</h1>
|
||||
<p>定时任务管理面板</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button type="button" class="nav-item active" data-view="tasks">
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
<span>任务清单</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<button type="button" class="btn btn-ghost btn-block" id="btn-refresh">刷新列表</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="overlay" id="overlay" hidden></div>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="打开菜单">菜单</button>
|
||||
<div class="masthead">
|
||||
<h2>定时任务管理</h2>
|
||||
<p class="brand-kicker">SproutClaw Cron</p>
|
||||
<h1>定时任务管理</h1>
|
||||
<p class="masthead-meta" id="summary-text">加载中…</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="btn-refresh-top">刷新</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-refresh-top" aria-label="刷新">刷新</button>
|
||||
</header>
|
||||
|
||||
<section class="document">
|
||||
<div class="doc-meta" id="stats" aria-live="polite"></div>
|
||||
<div class="runtime-grid" id="runtimes" aria-label="本机运行时环境"></div>
|
||||
|
||||
<div class="stats-grid" id="stats" aria-live="polite"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg class="search-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input type="search" id="filter" placeholder="按任务名或说明过滤…" autocomplete="off" />
|
||||
</div>
|
||||
<span class="last-refresh" id="last-refresh"></span>
|
||||
</div>
|
||||
|
||||
<section class="doc-section">
|
||||
<div class="section-head">
|
||||
<h3>任务清单</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-card">
|
||||
<table class="task-table" id="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -78,6 +58,109 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog class="panel-dialog" id="edit-dialog">
|
||||
<form class="edit-form" id="edit-form">
|
||||
<div class="panel-dialog-head">
|
||||
<div class="panel-dialog-title">
|
||||
<span class="kicker">编辑任务</span>
|
||||
<h3 id="edit-title">任务</h3>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" id="btn-edit-close">关闭</button>
|
||||
</div>
|
||||
<div class="panel-dialog-body">
|
||||
<label class="form-field">
|
||||
<span class="form-label">任务描述</span>
|
||||
<textarea id="edit-description" rows="3" placeholder="说明此任务的用途…"></textarea>
|
||||
</label>
|
||||
<fieldset class="tag-editor">
|
||||
<legend class="form-label">标签</legend>
|
||||
<p class="form-hint">最多 4 个,含运行时标签(如 Python、JavaScript)</p>
|
||||
<div class="tag-list" id="edit-tags"></div>
|
||||
<div class="tag-add-row">
|
||||
<input type="text" id="edit-tag-input" maxlength="32" placeholder="输入标签后回车或点击添加…" autocomplete="off" />
|
||||
<button type="button" class="btn btn-ghost" id="btn-tag-add">添加</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="cron-editor">
|
||||
<legend class="form-label">执行调度</legend>
|
||||
<div class="cron-grid">
|
||||
<label class="cron-field">
|
||||
<span class="cron-field-label">分</span>
|
||||
<input type="text" id="edit-minute" inputmode="numeric" placeholder="*" autocomplete="off" />
|
||||
</label>
|
||||
<label class="cron-field">
|
||||
<span class="cron-field-label">时</span>
|
||||
<input type="text" id="edit-hour" inputmode="numeric" placeholder="*" autocomplete="off" />
|
||||
</label>
|
||||
<label class="cron-field">
|
||||
<span class="cron-field-label">日</span>
|
||||
<input type="text" id="edit-dom" inputmode="numeric" placeholder="*" autocomplete="off" />
|
||||
</label>
|
||||
<label class="cron-field">
|
||||
<span class="cron-field-label">月</span>
|
||||
<select id="edit-month">
|
||||
<option value="*">每</option>
|
||||
<option value="1">1月</option>
|
||||
<option value="2">2月</option>
|
||||
<option value="3">3月</option>
|
||||
<option value="4">4月</option>
|
||||
<option value="5">5月</option>
|
||||
<option value="6">6月</option>
|
||||
<option value="7">7月</option>
|
||||
<option value="8">8月</option>
|
||||
<option value="9">9月</option>
|
||||
<option value="10">10月</option>
|
||||
<option value="11">11月</option>
|
||||
<option value="12">12月</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cron-field">
|
||||
<span class="cron-field-label">周</span>
|
||||
<select id="edit-dow">
|
||||
<option value="*">每</option>
|
||||
<option value="0">周日</option>
|
||||
<option value="1">周一</option>
|
||||
<option value="2">周二</option>
|
||||
<option value="3">周三</option>
|
||||
<option value="4">周四</option>
|
||||
<option value="5">周五</option>
|
||||
<option value="6">周六</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cron-presets" id="cron-presets">
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 * * *">每天 08:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 0 * * *">每天 00:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 * * 1">每周一 08:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 18 * * 5">每周五 18:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 9 * * 1-5">工作日 09:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 1 * *">每月 1 日 08:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 12 15 * *">每月 15 日 12:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 0 1 1 *">1 月 1 日 00:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 9 1 6 *">6 月 1 日 09:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 25 12 *">12 月 25 日 08:00</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="*/5 * * * *">每 5 分钟</button>
|
||||
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 */2 * * *">每 2 小时</button>
|
||||
</div>
|
||||
<ul class="cron-examples">
|
||||
<li><code>0 8 * * *</code> 每天 08:00</li>
|
||||
<li><code>0 8 * * 1</code> 每周一 08:00</li>
|
||||
<li><code>0 8 1 * *</code> 每月 1 日 08:00</li>
|
||||
<li><code>0 0 1 1 *</code> 每年 1 月 1 日 00:00</li>
|
||||
<li><code>0 9 * * 1-5</code> 周一到周五 09:00</li>
|
||||
<li><code>*/15 * * * *</code> 每 15 分钟</li>
|
||||
</ul>
|
||||
<p class="form-hint">Linux cron 五段:<strong>分 时 日 月 周</strong>。<code>*</code> 任意;日 1–31,月 1–12,周 0=周日 … 6=周六。日/周同时指定时取交集。</p>
|
||||
<p class="form-preview">当前表达式:<code id="edit-preview">—</code></p>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="panel-dialog-foot">
|
||||
<button type="button" class="btn" id="btn-edit-cancel">取消</button>
|
||||
<button type="submit" class="btn btn-primary" id="btn-edit-save">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="log-dialog" id="log-dialog">
|
||||
<div class="log-dialog-head">
|
||||
<div class="log-dialog-title">
|
||||
@@ -95,6 +178,6 @@
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,4 +13,5 @@ fi
|
||||
HOST="${SPROUTCLAW_CRON_WEB_HOST:-0.0.0.0}"
|
||||
PORT="${SPROUTCLAW_CRON_WEB_PORT:-8765}"
|
||||
|
||||
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT"
|
||||
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT" \
|
||||
--reload --reload-dir "$BACKEND" --reload-dir "$ROOT/lib"
|
||||
|
||||
Reference in New Issue
Block a user