app
Wiki Notify for Google Chat
Public Made by Adomby adom
Turn wiki.adom.inc notification emails into a live Google Chat board. One card, one row per issue - newest first, OPEN/CLOSED status, origination date, actor, and a View link - continuously updated in your own Chat space via your own webhook. Emails are swept out of your inbox (trash, archive, or label). Closed items linger until 6am the next day, then drop off. Delegates all auth to adom-google; the only secret is your webhook URL, kept in your private config.
← Commit history
Publish 1.4.0
6 files changed
+177−15
README.md+21−3@@ -137,11 +137,27 @@ inbox. A failed board post never sweeps the email; it retries next pass. | `meName` | `You` | your first name, used in the involvement line ("Drew opened this") | | `fullName` | auto (To header) | your wiki author name (e.g. "Drew Owens"), used to detect pages you authored | +## Staying alive and staying current (zero maintenance)++- **Cold boots self-heal.** `install-cron` wires three recovery triggers -+ a cron `@reboot` entry, a `~/.bashrc` snippet, and a Claude Code+ `UserPromptSubmit` hook - all calling `wiki-notify-gchat ensure`+ (throttled, 15 min). `ensure` restarts the cron service via passwordless+ sudo; if cron cannot start at all it launches a detached watch loop+ instead (and retires it once cron is back). Nobody ever runs sudo by hand.+- **Updates are automatic.** [adom-hook](https://wiki.adom.inc/adom/hook)+ (part of every adom/core install) runs `adom-wiki pkg update` on a+ throttled schedule, which pulls new wiki-notify-gchat versions and re-runs+ the install script. Nothing to do.+- Overlapping passes are impossible: `run` takes a file lock, so cron, the+ watch fallback, and `ensure` never double-post the board.+ ## Commands ```-setup | health | run [--dry-run] | backfill [--days N] | board [--dry-run]-status | watch | install-cron | uninstall-cron | install+setup | config [set <key> <value>] | health | run [--dry-run]+backfill [--days N] | board [--dry-run] | status | watch+install-cron | ensure [--force] | uninstall-cron | install ``` All commands accept `--json` for a `{status, data, hints}` envelope.@@ -159,7 +175,9 @@ All commands accept `--json` for a `{status, data, hints}` envelope. ## Troubleshooting - `health` says not authorized: run `adom-google auth`.-- Board stopped updating after a container cold boot: `sudo service cron start`.+- Board stopped updating after a container cold boot: it self-heals on the+ next terminal open or Claude prompt (see above); to force it immediately,+ `wiki-notify-gchat ensure --force`. - Board card duplicated (old one not deleted): you must be a **space manager** of the space - use a space you created. The stray card is one manual delete away; the tool logs a WARN when this happens.
SKILL.md+6−1@@ -68,7 +68,12 @@ WARN and the user must be made a space manager (or use a space they created). ## Pitfalls -- After a container cold boot cron may be stopped: `sudo service cron start`.+- Cold boots self-heal: `install-cron` wires @reboot + a bashrc snippet + a+ Claude UserPromptSubmit hook, all calling `wiki-notify-gchat ensure`+ (restarts cron via passwordless sudo, or runs a detached watch fallback).+ Never tell the user to run sudo by hand; `ensure --force` heals instantly.+- Updates arrive automatically via adom-hook's `adom-wiki pkg update` pass -+ do not build a separate updater. - Gmail search cannot match quoted URLs; precision comes from the `requireInBody` guard, not the query - do not "fix" the query by quoting. - A failed board post never sweeps the email; it retries next pass.
bin/__pycache__/wiki-notify-gchatcpython-312.pyc⋯ 1 unchanged line ⋯
bin/wiki-notify-gchat+148−9@@ -602,6 +602,16 @@ def cmd_health(as_json): def cmd_run(as_json, dry_run=False, quiet=False):+ # single-flight: cron, watch fallback, and ensure may all fire — never overlap+ import fcntl+ STATE_DIR.mkdir(parents=True, exist_ok=True)+ lock = open(STATE_DIR / "run.lock", "w")+ try:+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)+ except OSError:+ if not quiet:+ emit("OK: another pass is already running; skipped.")+ return cfg = load_config(as_json) state = load_state() ingested, skipped, errors = ingest(cfg, state, cfg["query"], sweep_mail=True, dry_run=dry_run)@@ -714,25 +724,152 @@ def cmd_install_cron(as_json): cfg = load_config(as_json) interval_min = max(1, int(cfg.get("pollSeconds", 120)) // 60) entry = f"*/{interval_min} * * * * {me} run --quiet >> {STATE_DIR}/cron.log 2>&1 {CRON_MARKER}"+ reboot_entry = f"@reboot {me} ensure --quiet --force >> {STATE_DIR}/cron.log 2>&1 {CRON_MARKER}" code, out, _ = run_cmd(["crontab", "-l"]) lines = [l for l in (out if code == 0 else "").splitlines() if CRON_MARKER not in l]- lines.append(entry)+ lines += [entry, reboot_entry] code, _, err = run_cmd(["crontab", "-"], stdin_text="\n".join(lines) + "\n") if code != 0: die(f"could not install crontab: {err.strip()}", ["Check that cron is available in this container."], as_json)- emit(f"OK: cron installed — polls every {interval_min} min.")- c, out, _ = run_cmd(["bash", "-c", "service cron status 2>&1 || true"])- if "running" not in out:- emit("Hint: cron service may not be running — `sudo service cron start` (needed after cold boot).")- emit(f"Hint: activity log at {LOG_PATH}; remove with `wiki-notify-gchat uninstall-cron`.")+ wired = ["cron */%d min" % interval_min, "@reboot ensure"]+ if wire_bashrc(me):+ wired.append("bashrc recovery snippet")+ if wire_claude_hook(me):+ wired.append("Claude prompt hook")+ emit(f"OK: installed — {', '.join(wired)}.")+ emit("Hint: cold boots self-heal: `ensure` restarts cron via passwordless sudo (or runs a watch")+ emit("Hint: fallback) the next time a terminal opens or a Claude prompt fires — no manual sudo needed.")+ cmd_ensure(as_json, quiet=True, force=True)+ emit(f"Hint: activity log at {LOG_PATH}; remove everything with `wiki-notify-gchat uninstall-cron`.")+++ENSURE_STAMP = STATE_DIR / "ensure.stamp"+WATCH_PID = STATE_DIR / "watch.pid"+BASHRC_MARKER = "# wiki-notify-gchat cold-boot recovery"+++def cron_running():+ code, out, _ = run_cmd(["bash", "-c", "pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1"])+ return code == 0+++def watch_alive():+ try:+ pid = int(WATCH_PID.read_text().strip())+ os.kill(pid, 0)+ return pid+ except Exception:+ return None+++def cmd_ensure(as_json, quiet=False, force=False):+ """Self-heal after a cold boot: start cron (passwordless sudo), or fall+ back to a detached watch loop; then run one pass. Cheap + throttled, so+ it is safe to call from bashrc / a Claude prompt hook on every trigger."""+ STATE_DIR.mkdir(parents=True, exist_ok=True)+ if not force and ENSURE_STAMP.exists():+ if time.time() - ENSURE_STAMP.stat().st_mtime < 900:+ if not quiet:+ emit("OK: checked recently; skipped (use --force to override).")+ return+ ENSURE_STAMP.write_text(str(int(time.time())))+ healed = []+ up = cron_running()+ if not up:+ run_cmd(["sudo", "-n", "service", "cron", "start"])+ up = cron_running()+ if up:+ healed.append("started cron")+ log_line("OK ensure: started cron after cold boot")+ if up:+ pid = watch_alive()+ if pid:+ try:+ os.kill(pid, 15)+ except Exception:+ pass+ WATCH_PID.unlink(missing_ok=True)+ healed.append("stopped watch fallback (cron is back)")+ else:+ if not watch_alive():+ me = os.path.realpath(sys.argv[0])+ p = subprocess.Popen(+ ["nohup", me, "watch"],+ stdout=open(STATE_DIR / "watch.log", "a"), stderr=subprocess.STDOUT,+ start_new_session=True)+ WATCH_PID.write_text(str(p.pid))+ healed.append(f"cron unavailable - started detached watch loop (pid {p.pid})")+ log_line(f"OK ensure: watch fallback started pid {p.pid}")+ try:+ cmd_run(False, dry_run=False, quiet=True)+ except SystemExit:+ pass+ if healed:+ emit(f"OK: wiki-notify-gchat recovered: {'; '.join(healed)}.")+ elif not quiet:+ emit(f"OK: healthy - cron {'running' if up else 'down'}, board current.")+++def wire_bashrc(me):+ rc = Path.home() / ".bashrc"+ content = rc.read_text() if rc.exists() else ""+ if BASHRC_MARKER in content:+ return False+ with rc.open("a") as f:+ f.write(f"\n{BASHRC_MARKER}\n({me} ensure --quiet >/dev/null 2>&1 &)\n")+ return True+++def wire_claude_hook(me):+ settings = Path.home() / ".claude" / "settings.json"+ try:+ data = json.loads(settings.read_text()) if settings.exists() else {}+ except Exception:+ return False+ if "wiki-notify-gchat" in json.dumps(data):+ return False+ hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])+ hooks.append({"hooks": [{"type": "command", "command": f"{me} ensure --quiet", "timeout": 15}]})+ settings.parent.mkdir(parents=True, exist_ok=True)+ settings.write_text(json.dumps(data, indent=2) + "\n")+ return True def cmd_uninstall_cron(as_json): code, out, _ = run_cmd(["crontab", "-l"]) lines = [l for l in (out if code == 0 else "").splitlines() if CRON_MARKER not in l] run_cmd(["crontab", "-"], stdin_text="\n".join(lines) + ("\n" if lines else ""))- emit("OK: cron entry removed.")+ pid = watch_alive()+ if pid:+ try:+ os.kill(pid, 15)+ except Exception:+ pass+ WATCH_PID.unlink(missing_ok=True)+ rc = Path.home() / ".bashrc"+ if rc.exists() and BASHRC_MARKER in rc.read_text():+ kept, skip = [], False+ for l in rc.read_text().splitlines():+ if l.strip() == BASHRC_MARKER:+ skip = True+ continue+ if skip:+ skip = False+ continue+ kept.append(l)+ rc.write_text("\n".join(kept) + "\n")+ settings = Path.home() / ".claude" / "settings.json"+ try:+ data = json.loads(settings.read_text())+ ups = data.get("hooks", {}).get("UserPromptSubmit", [])+ data["hooks"]["UserPromptSubmit"] = [+ h for h in ups+ if "wiki-notify-gchat" not in json.dumps(h)]+ settings.write_text(json.dumps(data, indent=2) + "\n")+ except Exception:+ pass+ emit("OK: cron entries, watch fallback, bashrc snippet, and Claude hook removed.") INT_KEYS = {"pollSeconds", "purgeHour", "maxRows"}@@ -812,8 +949,9 @@ Commands: board force-repost the board card (--dry-run prints the card JSON) status board contents summary + recent activity watch poll continuously in the foreground- install-cron poll from cron (survives session restarts)- uninstall-cron remove the cron entry+ install-cron poll from cron + wire cold-boot self-healing (@reboot, bashrc, Claude hook)+ ensure self-heal now: start cron (passwordless sudo) or a watch fallback, then run a pass+ uninstall-cron remove cron entries, watch fallback, bashrc snippet, Claude hook install symlink onto PATH """ @@ -843,6 +981,7 @@ def main(): "status": lambda: cmd_status(as_json), "watch": lambda: cmd_watch(as_json), "install-cron": lambda: cmd_install_cron(as_json),+ "ensure": lambda: cmd_ensure(as_json, quiet, "--force" in sys.argv), "uninstall-cron": lambda: cmd_uninstall_cron(as_json), "install": lambda: cmd_install(as_json), }
package.json+1−1@@ -2,7 +2,7 @@ "slug": "wiki-notify-gchat", "title": "Wiki Notify for Google Chat", "type": "app",- "version": "1.3.1",+ "version": "1.4.0", "description": "Turn wiki.adom.inc notification emails into a live Google Chat board. One card, one row per issue - newest first, OPEN/CLOSED status, origination date, actor, and a View link - continuously updated in your own Chat space via your own webhook. Emails are swept out of your inbox (trash, archive, or label). Closed items linger until 6am the next day, then drop off. Delegates all auth to adom-google; the only secret is your webhook URL, kept in your private config.", "tags": [ "cli",
page.json+1−1@@ -2,7 +2,7 @@ "slug": "wiki-notify-gchat", "title": "Wiki Notify for Google Chat", "type": "app",- "version": "1.3.1",+ "version": "1.4.0", "description": "Turn wiki.adom.inc notification emails into a live Google Chat board. One card, one row per issue - newest first, OPEN/CLOSED status, origination date, actor, and a View link - continuously updated in your own Chat space via your own webhook. Emails are swept out of your inbox (trash, archive, or label). Closed items linger until 6am the next day, then drop off. Delegates all auth to adom-google; the only secret is your webhook URL, kept in your private config.", "tags": [ "cli",