summaryrefslogtreecommitdiff
path: root/scripts/completion_notify.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/completion_notify.py')
-rw-r--r--scripts/completion_notify.py132
1 files changed, 132 insertions, 0 deletions
diff --git a/scripts/completion_notify.py b/scripts/completion_notify.py
new file mode 100644
index 0000000..e18796b
--- /dev/null
+++ b/scripts/completion_notify.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""completion-notice.md 变更通知:供 Hermes cron 调用。
+
+行为(严格):
+- 通知文件缺失: 静默退出 0,无任何输出
+- 文件存在: 计算 sha256;与上次已通知 hash 相同 -> 静默退出 0
+- hash 为新值: 把文件内容原样打印到 stdout 一次(供 cron 捕获后发 Slack),并原子记录该 hash
+- 每个新内容 hash 只打印一次;内容再次变化会再次打印
+- 状态文件损坏/不可读: 视为未通知过(打印一次)
+- 不依赖任何模型凭据;纯本地文件与 hash
+
+用法:
+ completion_notify.py [--notice PATH] [--state PATH] [--selftest]
+默认 notice=<cwd>/docs/completion-notice.md, state=<脚本所在目录>/completion_notify_state.json。
+脚本放项目中,由 Hermes 复制到 cron 脚本目录时建议显式传 --notice/--state。
+
+--selftest: 全部合成测试(临时文件模拟 缺失/新hash/重复/变更/损坏状态),不伪造真实结果。
+"""
+import argparse
+import hashlib
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+DEFAULT_NOTICE = Path.cwd() / "docs/completion-notice.md"
+DEFAULT_STATE = SCRIPT_DIR / "completion_notify_state.json"
+
+
+def content_hash(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def load_state(path: Path):
+ try:
+ v = json.loads(Path(path).read_text())
+ if isinstance(v, dict) and isinstance(v.get("hash"), str):
+ return v["hash"]
+ except Exception:
+ pass
+ return None
+
+
+def save_state(path: Path, h: str):
+ p = Path(path)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".notify_state.", suffix=".tmp")
+ try:
+ with os.fdopen(fd, "w") as fh:
+ fh.write(json.dumps({"hash": h}, indent=1) + "\n")
+ os.replace(tmp, p)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+ raise
+
+
+def run_once(notice_path, state_path, out=sys.stdout):
+ """返回 'printed' | 'silent' | 'missing'。打印发生在 out 上。"""
+ p = Path(notice_path)
+ try:
+ data = p.read_bytes()
+ except OSError:
+ return "missing" # 缺失静默
+ h = content_hash(data)
+ if load_state(state_path) == h:
+ return "silent"
+ out.write(data.decode("utf-8", "replace"))
+ save_state(state_path, h)
+ return "printed"
+
+
+def run_once_str(notice_path, state_path):
+ """测试用: 返回 (action, printed_text)。"""
+ import io
+ buf = io.StringIO()
+ action = run_once(notice_path, state_path, out=buf)
+ return action, buf.getvalue()
+
+
+def selftest():
+ ok = True
+
+ def check(name, cond):
+ nonlocal ok
+ print(("PASS " if cond else "FAIL ") + name)
+ ok = ok and cond
+
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ td = Path(td)
+ notice = td / "completion-notice.md"
+ state = td / "state" / "notify.json"
+ action, text = run_once_str(notice, state)
+ check("missing -> silent no output", action == "missing" and text == "")
+ notice.write_text("# 完成\n- 测试内容\n")
+ action, text = run_once_str(notice, state)
+ check("new content printed once", action == "printed" and text == notice.read_text())
+ action, text = run_once_str(notice, state)
+ check("same hash silent", action == "silent" and text == "")
+ notice.write_text("# 完成 v2\n")
+ action, text = run_once_str(notice, state)
+ check("changed content printed again", action == "printed" and text == "# 完成 v2\n")
+ action, text = run_once_str(notice, state)
+ check("second view silent", action == "silent" and text == "")
+ state.write_text("not-json{{{")
+ action, text = run_once_str(notice, state)
+ check("corrupt state treated as new", action == "printed" and text == "# 完成 v2\n")
+ check("state saved after corrupt", load_state(state) == content_hash("# 完成 v2\n".encode()))
+
+ print("SELFTEST=" + ("PASS" if ok else "FAIL"))
+ return 0 if ok else 1
+
+
+def main():
+ ap = argparse.ArgumentParser(description="completion-notice.md new-hash notifier")
+ ap.add_argument("--notice", default=str(DEFAULT_NOTICE))
+ ap.add_argument("--state", default=str(DEFAULT_STATE))
+ ap.add_argument("--selftest", action="store_true")
+ args = ap.parse_args()
+ if args.selftest:
+ return selftest()
+ action = run_once(args.notice, args.state)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())