← Commit history
.gitignoreadded+4
@@ -0,0 +1,4 @@+# NEVER commit webhook URLs or personal config - each member's webhook is a secret+config.json+*webhook*+*.secret
SKILL.mdadded+59
@@ -0,0 +1,59 @@+---+name: wiki-notify-gchat+description: Turn wiki.adom.inc notification emails into a live Google Chat board and sweep them out of the Gmail inbox. Use when the user wants wiki notifications in Google Chat instead of email, a chat board of open wiki issues, wiki emails auto-trashed/archived/labeled, or asks to set up, check, backfill, or change wiki-notify-gchat. Trigger words - wiki-notify-gchat, wiki notifications in chat, stop wiki emails, wiki notification board, sweep wiki emails, wiki issues board in gchat, forward wiki notifications.+user-invocable: true+---++# Wiki Notify for Google Chat++Polls Gmail for wiki.adom.inc notification emails, folds them into per-issue+state, and maintains ONE card in the user's own Chat space - a table with a+row per item (newest first, OPEN/CLOSED status, origination date, actor,+View link at the end) - then sweeps the emails out of the inbox. Closed items+drop off the board at 6am the next day.++## Fastest path (per team member - each uses their OWN space + webhook)++```bash+adom-wiki pkg install adom/wiki-notify-gchat   # if not installed+wiki-notify-gchat setup+```++Then have the user create their own Chat space (e.g. "Wiki Notifications")+and add an incoming webhook: space name > Apps & integrations > Webhooks >+Add webhook - and paste the URL into `"webhook"` in+`~/.config/wiki-notify-gchat/config.json`. The webhook URL is a personal+secret: it lives only in that private 0600 config, never in a repo (gitignore+it anywhere else).++```bash+wiki-notify-gchat health          # auth + config check (needs authorized adom-google)+wiki-notify-gchat run --dry-run   # preview what would be ingested + swept+wiki-notify-gchat backfill        # seed the board from the last 30 days (no sweeping)+wiki-notify-gchat run             # ingest + sweep + post the board+wiki-notify-gchat install-cron    # keep the board live every 2 min+```++## Key config choices (`~/.config/wiki-notify-gchat/config.json`)++- `action`: what happens to swept emails - `trash` (default, recoverable 30+  days), `archive`, `label` (uses `labelName`, auto-created), or `keep`.+- `timezone`: IANA zone (e.g. `America/New_York`) so the 6am closed-item+  purge lands in the user's morning; container default is often UTC.+- `purgeHour`, `maxRows`, `pollSeconds`, `boardTitle` as needed.++## How the board updates (why you may see a delete)++Webhooks can only create messages, so each change POSTs a fresh card and+deletes the previous one via adom-google (the user is a manager of their own+space). Always exactly one board card. If deletion ever fails the tool logs+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`.+- 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.+- `board --dry-run` prints the exact card JSON; `status` shows counts + log.+- Add `--json` to any command for a `{status, data, hints}` envelope.
bin/__pycache__/wiki-notify-gchatcpython-312.pycadded
⋯ 1 unchanged line ⋯
bin/wiki-notify-gchatadded+663
@@ -0,0 +1,663 @@+#!/usr/bin/env python3+"""wiki-notify-gchat — wiki.adom.inc notification emails become a live Google Chat board.++Polls Gmail for wiki notification emails, folds them into per-item state+(issue opened / commented / mentioned / closed), renders ONE card message — a+table of items, newest first — into your Chat space via your incoming webhook,+then sweeps the emails out of the inbox (trash / archive / label).++The card is replaced on every change: the webhook POSTs the fresh card, then+the previous card is deleted via adom-google (you created the space, so you+are a space manager and may delete the webhook's messages). Closed items show+CLOSED until 6am the next day, then drop off the board.++Gmail + Chat auth is delegated entirely to the adom-google CLI; the only+secret this tool holds is your webhook URL, in your private config (0600).+"""+import json+import os+import re+import subprocess+import sys+import time+import hashlib+from datetime import datetime, timedelta, timezone+from pathlib import Path++CONFIG_PATH = Path.home() / ".config" / "wiki-notify-gchat" / "config.json"+STATE_DIR = Path.home() / ".local" / "state" / "wiki-notify-gchat"+STATE_PATH = STATE_DIR / "state.json"+LOG_PATH = STATE_DIR / "activity.log"+GMAIL = "https://gmail.googleapis.com/gmail/v1/users/me"+CHAT = "https://chat.googleapis.com/v1"+CRON_MARKER = "# wiki-notify-gchat poller"+MAX_PROCESSED = 2000++DEFAULT_CONFIG = {+    "webhook": "",+    "query": "from:[email protected] in:inbox",+    "requireInBody": "wiki.adom.inc",+    "action": "trash",+    "labelName": "wiki-notify",+    "pollSeconds": 120,+    "purgeHour": 6,+    "timezone": "",+    "maxRows": 40,+    "boardTitle": "Wiki Notifications",+}++OPEN_COLOR, CLOSED_COLOR = "#22c55e", "#94a3b8"+++def emit(line):+    print(line)+++def die(msg, hints=(), as_json=False):+    if as_json:+        print(json.dumps({"status": "error", "error": msg, "hints": list(hints)}))+    else:+        emit(f"ERROR: {msg}")+        for h in hints:+            emit(f"Hint: {h}")+    sys.exit(1)+++def now_iso():+    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")+++def log_line(text):+    STATE_DIR.mkdir(parents=True, exist_ok=True)+    with LOG_PATH.open("a") as f:+        f.write(f"{now_iso()} {text}\n")+++def load_config(as_json=False):+    if not CONFIG_PATH.exists():+        die("no config found.",+            ["Run `wiki-notify-gchat setup` to write a starter config.",+             f"Config lives at {CONFIG_PATH}."], as_json)+    try:+        cfg = {**DEFAULT_CONFIG, **json.loads(CONFIG_PATH.read_text())}+    except Exception as e:+        die(f"config is not valid JSON: {e}", [f"Fix {CONFIG_PATH} and re-run."], as_json)+    return cfg+++def load_state():+    try:+        return json.loads(STATE_PATH.read_text())+    except Exception:+        return {"items": {}, "processed": {}, "boardMsg": "", "lastRenderHash": ""}+++def save_state(state):+    STATE_DIR.mkdir(parents=True, exist_ok=True)+    proc = state.get("processed", {})+    if len(proc) > MAX_PROCESSED:+        state["processed"] = dict(sorted(proc.items(), key=lambda kv: kv[1])[-MAX_PROCESSED:])+    STATE_PATH.write_text(json.dumps(state))+++def tzinfo_of(cfg):+    name = cfg.get("timezone") or ""+    if name:+        try:+            from zoneinfo import ZoneInfo+            return ZoneInfo(name)+        except Exception:+            pass+    return datetime.now().astimezone().tzinfo+++def run_cmd(args, stdin_text=None):+    try:+        p = subprocess.run(args, input=stdin_text, capture_output=True, text=True, timeout=120)+        return p.returncode, p.stdout, p.stderr+    except FileNotFoundError:+        return 127, "", f"{args[0]}: not found"+    except subprocess.TimeoutExpired:+        return 124, "", f"{args[0]}: timed out"+++def gapi(url, method=None, body=None, params=None):+    """Call any Google API via adom-google (user auth); return parsed JSON."""+    args = ["adom-google", "api", url]+    if method:+        args += ["-X", method]+    if body is not None:+        args += ["-d", json.dumps(body)]+    for k, v in (params or {}).items():+        args += ["-q", f"{k}={v}"]+    code, out, err = run_cmd(args)+    if code != 0:+        raise RuntimeError(f"adom-google api failed ({code}): {(err or out).strip()[:300]}")+    m = re.search(r"^HTTP (\d+) ", out + "\n" + err, re.M)+    if m and not m.group(1).startswith("2"):+        raise RuntimeError(f"HTTP {m.group(1)}: {out.strip()[:300]}")+    brace = out.find("{")+    return json.loads(out[brace:]) if brace >= 0 else {}+++def webhook_post(cfg, payload):+    """POST a message through the incoming webhook; returns the message name."""+    import urllib.request+    req = urllib.request.Request(+        cfg["webhook"], data=json.dumps(payload).encode(),+        headers={"Content-Type": "application/json; charset=UTF-8"}, method="POST")+    with urllib.request.urlopen(req, timeout=30) as r:+        resp = json.loads(r.read().decode())+    if resp.get("error"):+        raise RuntimeError(f"webhook post failed: {resp['error'].get('message')}")+    return resp.get("name", "")+++# ---------- email parsing ----------++def headers_of(msg):+    return {h["name"].lower(): h["value"] for h in msg.get("payload", {}).get("headers", [])}+++def plain_body(msg):+    import base64++    def walk(part):+        if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):+            return base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", "replace")+        for p in part.get("parts", []) or []:+            got = walk(p)+            if got:+                return got+        return None++    return walk(msg.get("payload", {})) or ""+++EVENT_PATTERNS = [+    (re.compile(r"^New issue:\s*(.+)$"), "opened", "open"),+    (re.compile(r"^Reopened:\s*(.+)$"), "reopened", "open"),+    (re.compile(r"^Closed:\s*(.+)$"), "closed", "closed"),+    (re.compile(r"^New comment on:\s*(.+)$"), "comment", None),+    (re.compile(r"^(.+?) mentioned you on:\s*(.+)$"), "mention", None),+]+++def parse_notification(msg):+    """Return an event dict for a wiki notification email, or None."""+    import html+    h = headers_of(msg)+    subject = html.unescape(h.get("subject", ""))+    body = html.unescape(plain_body(msg))+    m = re.match(r"^\[([^\]]+)\]\s*(.+?)(?:\s*\(#(\d+)\))?$", subject)+    if not m:+        return None+    page, rest, num = m.group(1), m.group(2), m.group(3)+    event, status, title, actor = "activity", None, rest, None+    for pat, ev, st in EVENT_PATTERNS:+        em = pat.match(rest)+        if em:+            event, status = ev, st+            if ev == "mention":+                actor, title = em.group(1), em.group(2)+            else:+                title = em.group(1)+            break+    if not actor:+        am = re.search(r"Hi [^,]+,\s+(.+?)\s+(?:opened|commented|closed|reopened|merged|replied|mentioned)", body)+        if am:+            actor = am.group(1).strip("*")+    url = None+    for u in re.findall(r"\(\s*(https://wiki\.adom\.inc/[^\s)]+)\s*\)", body):+        url = u+        break+    ts = int(msg.get("internalDate", "0"))+    return {+        "key": f"{page}#{num or title[:40]}",+        "page": page, "num": num, "title": title.strip(),+        "event": event, "status": status, "actor": actor, "url": url, "ts": ts,+    }+++def apply_event(items, ev):+    it = items.get(ev["key"])+    if not it:+        it = {"page": ev["page"], "num": ev["num"], "title": ev["title"],+              "status": "open", "openedAt": ev["ts"], "lastAt": 0,+              "lastEvent": "", "actor": ev["actor"] or "", "url": ev["url"] or "",+              "closedAt": None, "events": 0}+        items[ev["key"]] = it+    it["events"] += 1+    it["openedAt"] = min(it["openedAt"], ev["ts"])+    if ev["ts"] >= it["lastAt"]:+        it["lastAt"] = ev["ts"]+        it["lastEvent"] = ev["event"]+        it["title"] = ev["title"] or it["title"]+        if ev["actor"]:+            it["actor"] = ev["actor"]+        if ev["status"]:+            it["status"] = ev["status"]+            it["closedAt"] = ev["ts"] if ev["status"] == "closed" else None+    if ev["url"] and not it["url"]:+        it["url"] = ev["url"]+++def purge_items(items, cfg):+    """Drop closed items once local time passes purgeHour on the day after closing."""+    tz = tzinfo_of(cfg)+    now = datetime.now(tz)+    dropped = []+    for key, it in list(items.items()):+        if it["status"] == "closed" and it.get("closedAt"):+            closed = datetime.fromtimestamp(it["closedAt"] / 1000, tz)+            deadline = (closed + timedelta(days=1)).replace(+                hour=int(cfg.get("purgeHour", 6)), minute=0, second=0, microsecond=0)+            if now >= deadline:+                dropped.append(key)+                del items[key]+    return dropped+++# ---------- card rendering ----------++def fmt_date(ts, tz):+    return datetime.fromtimestamp(ts / 1000, tz).strftime("%b %-d")+++def fmt_datetime(ts, tz):+    return datetime.fromtimestamp(ts / 1000, tz).strftime("%b %-d %H:%M")+++EVENT_LABEL = {"opened": "opened", "comment": "new comment", "mention": "mentioned you",+               "closed": "closed", "reopened": "reopened", "activity": "activity"}+++def render_card(items, cfg):+    tz = tzinfo_of(cfg)+    rows = sorted(items.values(), key=lambda it: -it["lastAt"])+    n_open = sum(1 for it in rows if it["status"] == "open")+    n_closed = len(rows) - n_open+    max_rows = int(cfg.get("maxRows", 40))+    shown, overflow = rows[:max_rows], max(0, len(rows) - max_rows)+    widgets = []+    for it in shown:+        is_open = it["status"] == "open"+        status_html = (f'<font color="{OPEN_COLOR}"><b>OPEN</b></font>' if is_open+                       else f'<font color="{CLOSED_COLOR}"><b>CLOSED</b></font>')+        num = f" #{it['num']}" if it["num"] else ""+        top = f"{it['page']}{num} · opened {fmt_date(it['openedAt'], tz)}" + (+            f" · by {it['actor']}" if it["actor"] else "")+        bottom = f"last: {EVENT_LABEL.get(it['lastEvent'], it['lastEvent'])} · {fmt_datetime(it['lastAt'], tz)}"+        w = {"decoratedText": {+            "topLabel": top,+            "text": f"{status_html} {it['title'][:200]}",+            "bottomLabel": bottom,+            "wrapText": True,+        }}+        if it["url"]:+            w["decoratedText"]["button"] = {+                "text": "View", "onClick": {"openLink": {"url": it["url"]}}}+        widgets.append(w)+    if overflow:+        widgets.append({"textParagraph": {"text": f"<i>+{overflow} older item(s) not shown</i>"}})+    if not widgets:+        widgets = [{"textParagraph": {"text": "No wiki notifications. Inbox clean."}}]+    subtitle = f"{n_open} open · {n_closed} closed · updated {datetime.now(tz).strftime('%b %-d %H:%M')}"+    return {"cardsV2": [{"cardId": "wiki-notify-board", "card": {+        "header": {"title": cfg.get("boardTitle", "Wiki Notifications"), "subtitle": subtitle},+        "sections": [{"widgets": widgets}],+    }}]}+++def items_hash(items):+    key = json.dumps(items, sort_keys=True)+    return hashlib.sha256(key.encode()).hexdigest()+++def push_board(cfg, state, dry_run=False, force=False):+    """Re-render the board; post + swap the card if content changed."""+    h = items_hash(state["items"])+    if h == state.get("lastRenderHash") and not force:+        return False+    card = render_card(state["items"], cfg)+    if dry_run:+        return True+    old = state.get("boardMsg", "")+    name = webhook_post(cfg, card)+    state["boardMsg"] = name+    state["lastRenderHash"] = h+    save_state(state)+    if old:+        try:+            gapi(f"{CHAT}/{old}", "DELETE")+        except RuntimeError as e:+            log_line(f"WARN could not delete old board card {old}: {e}")+    log_line(f"OK board updated ({len(state['items'])} item(s)) {name}")+    return True+++# ---------- sweeping ----------++def label_id(name):+    listing = gapi(f"{GMAIL}/labels")+    for lb in listing.get("labels", []):+        if lb["name"].lower() == name.lower():+            return lb["id"]+    created = gapi(f"{GMAIL}/labels", "POST", {+        "name": name, "labelListVisibility": "labelShow", "messageListVisibility": "show"})+    return created["id"]+++def sweep(cfg, msg_id):+    action = cfg.get("action", "trash")+    if action == "trash":+        gapi(f"{GMAIL}/messages/{msg_id}/trash", "POST", {})+    elif action == "archive":+        gapi(f"{GMAIL}/messages/{msg_id}/modify", "POST",+             {"removeLabelIds": ["INBOX", "UNREAD"]})+    elif action == "label":+        lid = label_id(cfg.get("labelName", "wiki-notify"))+        gapi(f"{GMAIL}/messages/{msg_id}/modify", "POST",+             {"removeLabelIds": ["INBOX", "UNREAD"], "addLabelIds": [lid]})+    elif action == "keep":+        pass+    else:+        raise RuntimeError(f"unknown action '{action}' (use trash | archive | label | keep)")+++# ---------- ingest ----------++def ingest(cfg, state, query, sweep_mail=True, dry_run=False):+    """Pull matching emails, fold into item state. Returns (ingested, skipped, errors)."""+    ingested, skipped, errors = [], 0, []+    page_token = None+    while True:+        params = {"q": query, "maxResults": 100}+        if page_token:+            params["pageToken"] = page_token+        listing = gapi(f"{GMAIL}/messages", params=params)+        for m in listing.get("messages", []) or []:+            mid = m["id"]+            if mid in state["processed"]:+                continue+            try:+                msg = gapi(f"{GMAIL}/messages/{mid}")+                need = cfg.get("requireInBody")+                if need and need not in plain_body(msg):+                    skipped += 1+                    continue+                ev = parse_notification(msg)+                if not ev:+                    skipped += 1+                    continue+                if not dry_run:+                    apply_event(state["items"], ev)+                    if sweep_mail:+                        sweep(cfg, mid)+                    state["processed"][mid] = now_iso()+                    save_state(state)+                    log_line(f"OK ingest {ev['event']} {ev['key']} ({cfg.get('action') if sweep_mail else 'no-sweep'}) {mid}")+                ingested.append(ev)+            except Exception as e:+                errors.append(f"{mid}: {e}")+                log_line(f"ERROR ingest {mid} {e}")+        page_token = listing.get("nextPageToken")+        if not page_token:+            break+    return ingested, skipped, errors+++# ---------- commands ----------++def cmd_setup(as_json):+    if CONFIG_PATH.exists():+        emit(f"OK: config already exists at {CONFIG_PATH} (left untouched).")+        emit("Hint: run `wiki-notify-gchat health` to validate it.")+        return+    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)+    CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")+    os.chmod(CONFIG_PATH, 0o600)+    emit(f"OK: wrote starter config to {CONFIG_PATH}")+    emit("Hint: create your own Chat space (e.g. 'Wiki Notifications'), then add an incoming webhook:")+    emit("Hint:   space name > Apps & integrations > Webhooks > Add webhook, copy the URL.")+    emit(f"Hint: paste that URL into \"webhook\" in {CONFIG_PATH} — it is YOUR secret; never commit it.")+    emit("Hint: action is 'trash' (default, recoverable 30 days), 'archive', 'label' (uses labelName), or 'keep'.")+    emit("Hint: set \"timezone\" (IANA, e.g. America/New_York) so the 6am closed-item purge matches your morning.")+    emit("Hint: then `wiki-notify-gchat health`, `run --dry-run`, `backfill --days 30`, and `install-cron`.")+++def cmd_health(as_json):+    problems, hints = [], []+    code, out, _ = run_cmd(["adom-google", "status"])+    if code != 0:+        problems.append("adom-google is not installed")+        hints.append("Install/authorize adom-google first (wiki.adom.inc/adom/adom-google).")+    elif "Refresh token: present" not in out:+        problems.append("adom-google is not authorized")+        hints.append("Run `adom-google auth` — gmail.modify + chat.messages scopes are required.")+    cfg = None+    if not CONFIG_PATH.exists():+        problems.append("no config")+        hints.append("Run `wiki-notify-gchat setup`.")+    else:+        try:+            cfg = {**DEFAULT_CONFIG, **json.loads(CONFIG_PATH.read_text())}+            if not re.match(r"https://chat\.googleapis\.com/v1/spaces/[^/]+/messages\?", cfg.get("webhook", "")):+                problems.append("webhook is not set (or not a Chat incoming-webhook URL)")+                hints.append("Create a webhook in YOUR space (Apps & integrations > Webhooks) and paste it into config.")+            if cfg.get("action") not in ("trash", "archive", "label", "keep"):+                problems.append(f"unknown action '{cfg.get('action')}'")+                hints.append("Use trash | archive | label | keep.")+        except Exception as e:+            problems.append(f"config unreadable: {e}")+            hints.append(f"Fix {CONFIG_PATH}.")+    in_cron = CRON_MARKER in (run_cmd(["crontab", "-l"])[1] or "")+    if as_json:+        print(json.dumps({"status": "error" if problems else "ok",+                          "data": {"config": str(CONFIG_PATH), "cron": in_cron, "problems": problems},+                          "hints": hints}))+        sys.exit(1 if problems else 0)+    if problems:+        emit("ERROR: " + "; ".join(problems))+        for h in hints:+            emit(f"Hint: {h}")+        sys.exit(1)+    emit(f"OK: authorized, config valid, action={cfg['action']}, cron {'installed' if in_cron else 'NOT installed'}.")+    if not in_cron:+        emit("Hint: run `wiki-notify-gchat install-cron` so the board keeps updating without a live session.")+++def cmd_run(as_json, dry_run=False, quiet=False):+    cfg = load_config(as_json)+    state = load_state()+    ingested, skipped, errors = ingest(cfg, state, cfg["query"], sweep_mail=True, dry_run=dry_run)+    dropped = [] if dry_run else purge_items(state["items"], cfg)+    if dropped:+        save_state(state)+        log_line(f"OK purged {len(dropped)} closed item(s): {', '.join(dropped)}")+    board_changed = False+    if not dry_run:+        try:+            board_changed = push_board(cfg, state)+        except Exception as e:+            errors.append(f"board: {e}")+            log_line(f"ERROR board {e}")+    data = {"ingested": [f"{e['event']} {e['key']}" for e in ingested], "skipped": skipped,+            "purged": dropped, "boardUpdated": board_changed, "errors": errors}+    if as_json:+        print(json.dumps({"status": "error" if errors else "ok", "data": data, "hints": []}))+        sys.exit(1 if errors else 0)+    verb = "would ingest" if dry_run else "ingested"+    if not quiet or ingested or dropped or errors:+        emit(f"{'ERROR' if errors else 'OK'}: {verb} {len(ingested)}, purged {len(dropped)}, "+             f"board {'updated' if board_changed else 'unchanged'}, {len(errors)} error(s).")+    for e in ingested:+        emit(f"ITEM: {e['event']:8s} {e['key']}  {e['title'][:70]}")+    for err in errors:+        emit(f"ERROR: {err}")+    if errors:+        emit("Hint: failed emails stay in the inbox and retry next pass; check `wiki-notify-gchat health`.")+        sys.exit(1)+    if dry_run and ingested:+        emit("Hint: run `wiki-notify-gchat run` (no --dry-run) to ingest + sweep + update the board.")+++def cmd_backfill(as_json, days=30, dry_run=False):+    cfg = load_config(as_json)+    state = load_state()+    base = re.sub(r"\s*in:inbox\s*", " ", cfg["query"]).strip()+    query = f"{base} newer_than:{days}d in:anywhere"+    ingested, skipped, errors = ingest(cfg, state, query, sweep_mail=False, dry_run=dry_run)+    board_changed = False+    if not dry_run:+        try:+            board_changed = push_board(cfg, state)+        except Exception as e:+            errors.append(f"board: {e}")+    if as_json:+        print(json.dumps({"status": "error" if errors else "ok",+                          "data": {"ingested": len(ingested), "skipped": skipped,+                                   "boardUpdated": board_changed, "errors": errors}, "hints": []}))+        sys.exit(1 if errors else 0)+    emit(f"{'ERROR' if errors else 'OK'}: backfilled {len(ingested)} event(s) from the last {days}d "+         f"({skipped} skipped), board {'updated' if board_changed else 'unchanged'}.")+    for e in ingested:+        emit(f"ITEM: {e['event']:8s} {e['key']}  {e['title'][:70]}")+    for err in errors:+        emit(f"ERROR: {err}")+    if errors:+        sys.exit(1)+++def cmd_board(as_json, dry_run=False):+    cfg = load_config(as_json)+    state = load_state()+    if dry_run:+        card = render_card(state["items"], cfg)+        print(json.dumps(card, indent=2)[:6000])+        return+    changed = push_board(cfg, state, force=True)+    emit(f"OK: board {'reposted' if changed else 'unchanged'} ({len(state['items'])} item(s)).")+++def cmd_status(as_json):+    state = load_state()+    items = state.get("items", {})+    n_open = sum(1 for it in items.values() if it["status"] == "open")+    in_cron = CRON_MARKER in (run_cmd(["crontab", "-l"])[1] or "")+    tail = "\n".join(LOG_PATH.read_text().splitlines()[-5:]) if LOG_PATH.exists() else ""+    if as_json:+        print(json.dumps({"status": "ok", "data": {+            "items": len(items), "open": n_open, "closed": len(items) - n_open,+            "processed": len(state.get("processed", {})), "boardMsg": state.get("boardMsg", ""),+            "cron": in_cron, "logTail": tail}, "hints": []}))+        return+    emit(f"OK: {len(items)} item(s) on board ({n_open} open, {len(items) - n_open} closed) · "+         f"{len(state.get('processed', {}))} email(s) processed all-time · cron: {'yes' if in_cron else 'no'}")+    if tail:+        emit(tail)+    if not in_cron:+        emit("Hint: run `wiki-notify-gchat install-cron` to keep the board live.")+++def cmd_watch(as_json):+    cfg = load_config(as_json)+    interval = int(cfg.get("pollSeconds", 120))+    emit(f"OK: watching every {interval}s (Ctrl-C to stop).")+    while True:+        try:+            cmd_run(False, dry_run=False, quiet=True)+        except SystemExit:+            pass+        time.sleep(interval)+++def cmd_install_cron(as_json):+    me = os.path.realpath(sys.argv[0])+    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}"+    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)+    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`.")+++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.")+++def cmd_install(as_json):+    target = Path.home() / ".local" / "bin" / "wiki-notify-gchat"+    target.parent.mkdir(parents=True, exist_ok=True)+    src = Path(os.path.realpath(sys.argv[0]))+    if target != src:+        if target.exists() or target.is_symlink():+            target.unlink()+        target.symlink_to(src)+    emit(f"OK: installed at {target}")+    emit("Hint: run `wiki-notify-gchat setup`, then `health`, then `run --dry-run`.")+++USAGE = """wiki-notify-gchat — wiki notification emails become a live Google Chat board+Usage: wiki-notify-gchat <command> [--json] [--dry-run] [--quiet] [--days N]+Commands:+  setup           write a starter config (~/.config/wiki-notify-gchat/config.json)+  health          check auth, config, and cron+  run             one poll pass: ingest + sweep emails, purge, update the board card+  backfill        seed the board from past emails (default 30 days, incl. trash) - no sweeping+  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         symlink onto PATH+"""+++def main():+    args = sys.argv[1:]+    as_json = "--json" in args+    dry = "--dry-run" in args+    quiet = "--quiet" in args+    days = 30+    if "--days" in args:+        try:+            days = int(args[args.index("--days") + 1])+        except (IndexError, ValueError):+            die("--days needs a number", ["e.g. `wiki-notify-gchat backfill --days 14`"], as_json)+    positional = [a for a in args if not a.startswith("--") and not a.isdigit()]+    cmd = positional[0] if positional else "help"+    table = {+        "setup": lambda: cmd_setup(as_json),+        "health": lambda: cmd_health(as_json),+        "run": lambda: cmd_run(as_json, dry, quiet),+        "backfill": lambda: cmd_backfill(as_json, days, dry),+        "board": lambda: cmd_board(as_json, dry),+        "status": lambda: cmd_status(as_json),+        "watch": lambda: cmd_watch(as_json),+        "install-cron": lambda: cmd_install_cron(as_json),+        "uninstall-cron": lambda: cmd_uninstall_cron(as_json),+        "install": lambda: cmd_install(as_json),+    }+    if cmd not in table:+        print(USAGE)+        if cmd != "help":+            die(f"unknown command '{cmd}'", ["See usage above."], as_json)+        return+    table[cmd]()+++if __name__ == "__main__":+    main()
docs/board.pngadded
⋯ 1 unchanged line ⋯
docs/dry-run.pngadded
⋯ 1 unchanged line ⋯
docs/hero.pngadded
⋯ 1 unchanged line ⋯
install.shadded+10
@@ -0,0 +1,10 @@+#!/bin/sh+set -e++mkdir -p "$HOME/.local/bin"+ln -sfn "$PWD/bin/wiki-notify-gchat" "$HOME/.local/bin/wiki-notify-gchat" 2>/dev/null || cp "$PWD/bin/wiki-notify-gchat" "$HOME/.local/bin/wiki-notify-gchat"+chmod +x "$PWD/bin/wiki-notify-gchat"++echo "OK: wiki-notify-gchat installed at ~/.local/bin/wiki-notify-gchat"+echo "Hint: run \`wiki-notify-gchat setup\`, create YOUR OWN Chat space + incoming webhook, paste it into the config."+echo "Hint: wiki-notify-gchat needs an authorized adom-google (run \`adom-google status\` to check)."
package.jsonadded+43
@@ -0,0 +1,43 @@+{+  "slug": "wiki-notify-gchat",+  "title": "Wiki Notify for Google Chat",+  "type": "app",+  "version": "1.0.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",+    "gmail",+    "google-chat",+    "notifications",+    "wiki"+  ],+  "dependencies": {},+  "discovery_triggers": [+    "wiki notifications in google chat",+    "stop wiki emails cluttering my inbox",+    "wiki notification board",+    "forward wiki notifications to chat",+    "sweep wiki emails out of my inbox",+    "use wiki-notify-gchat"+  ],+  "discovery_pitch": "Reach for wiki-notify-gchat when wiki.adom.inc notification emails should become a live, self-cleaning board in Google Chat instead of inbox clutter.",+  "sample_prompts": [+    {+      "label": "Set it up",+      "prompt": "Make wiki.adom.inc notification emails show up as a board in my Google Chat and delete them from my inbox"+    },+    {+      "label": "Keep the mail",+      "prompt": "Set up the wiki notification chat board but archive the emails instead of trashing them"+    }+  ],+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "hero": {+    "headline": "Wiki Notify for Google Chat",+    "subhead": "Wiki notifications become a live Chat board - your inbox stays clean.",+    "screenshot": "docs/hero.png"+  }+}
uninstall.shadded+6
@@ -0,0 +1,6 @@+#!/bin/sh+set -e++"$HOME/.local/bin/wiki-notify-gchat" uninstall-cron 2>/dev/null || true+rm -f "$HOME/.local/bin/wiki-notify-gchat"+echo "OK: wiki-notify-gchat removed (config kept at ~/.config/wiki-notify-gchat)."