← Commit history

v2.0.3: edit/delete -> adom-google hints, publish auto-generates masked README, adom-google docs

John Lauer ·f2b0d5d4dd ·4mo ago ·parent 1185444
6 files changed +219−14
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-gchat"-version = "2.0.2"+version = "2.0.3" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-gchat"-version = "2.0.2"+version = "2.0.3" edition = "2021" description = "Post to Google Chat from any Adom container. Org-customizable." 
README.md+29
@@ -70,6 +70,34 @@ text (with Google Chat markdown) to Google's incoming-webhook API. No server, no OAuth, no open ports — the webhook URL is the only credential. Every message is attributed: `*OrgName (on behalf of username)* message`. +## Need more than posting? → [adom-google](/john/adom-google)++adom-gchat sends through a Google Chat **incoming webhook**, which is **post-only**+and posts *as the bot* (attributed "Kel (on behalf of …)"). Webhooks **cannot+edit, delete, read, react to, or list** messages — that's a Google limitation,+not ours.++For any of that, use **[adom-google](/john/adom-google)** — the Google Workspace+CLI with full Google Chat API access. It posts *as you* (your own identity, no+prefix) and can do the whole message lifecycle:++```bash+adom-google chat send --to <space|email> "posted as me, no webhook"+adom-google chat read --from <space|email>          # read recent messages+adom-google api -X PATCH  '…/v1/<message-name>?updateMask=text' -d '{"text":"…"}'   # edit+adom-google api -X DELETE '…/v1/<message-name>'      # delete+```++Quick guide:++| You want to… | Use |+|---|---|+| Post an **attributed bot message** to a team space (deploys, alerts) | **adom-gchat** (`send`) |+| **Edit / delete / read / react** to messages, or post **as yourself** | **adom-google** |++`adom-gchat edit` and `adom-gchat delete` exist only to print the exact+adom-google commands — they don't (can't) edit/delete via the webhook.+ ## Sharing across your org — who does what  adom-gchat is per-org (NTX Embedded, Teaazers, Cosmiic, you). **One person sets@@ -201,6 +229,7 @@ masters in `kel/original/`. | `adom-gchat preview` | Preview a Cards v2 card in a Hydrogen webview before posting | | `adom-gchat install` | Deploy skill + bash completions; auto-import your org's config | | `adom-gchat publish --org <org>` | Publish your org's config to a private org page (add `--include-webhook-urls` to also store shared URLs) |+| `adom-gchat edit` / `delete` | Not supported by webhooks — prints the `adom-google` commands to do it |  ## Security 
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-gchat",-  "version": "2.0.2",+  "version": "2.0.3",   "type": "app",   "description": "Post to Google Chat from any Adom container. Webhook-based, no OAuth. Each org configures its own spaces + attribution, then publishes an org-scoped wiki page so teammates auto-discover it.",   "homepage": "https://wiki.adom.inc/adom/adom-gchat",
skills/adom-gchat/SKILL.md+25
@@ -41,6 +41,31 @@ adom-gchat health adom-gchat setup ``` +## Deeper Google Chat features → suggest `adom-google`++adom-gchat is a Google Chat **incoming webhook**: it can only **post** new+messages, *as the bot*. It **cannot edit, delete, read, react to, or list**+messages — that's a webhook limitation.++**When the user asks for any of those, suggest [adom-google](/john/adom-google)**+— the Google Workspace CLI with full Chat API access (it posts as the *user*,+and can do the whole message lifecycle):++| User wants… | Route to |+|---|---|+| Post an attributed bot message (deploy/alert) to a space | **adom-gchat** `send` |+| **Edit** a message | `adom-google api -X PATCH '…/v1/<message>?updateMask=text' -d '{"text":"…"}'` |+| **Delete** a message | `adom-google api -X DELETE '…/v1/<message>'` |+| **Read** recent messages | `adom-google chat read --from <space|email>` |+| Post **as the user** (no bot prefix), DM a person, react | `adom-google chat send …` |++`adom-gchat edit` and `adom-gchat delete` are **stubs that print these exact+commands** — running them never edits/deletes via the webhook. If the user+doesn't have adom-google, offer to install it ("install adom-google"). Note:+Google Chat only lets you edit/delete messages posted under the *same* identity,+so a message must have been posted via `adom-google chat send` (not the webhook)+to edit it later.+ ## Setting up for your org  1. Open your Google Chat space > Settings > Apps & integrations > Webhooks
src/main.rs+162−11
@@ -80,6 +80,22 @@ enum Cmd {         #[arg(long)]         include_webhook_urls: bool,     },+    /// Edit a message you already posted. Webhooks are POST-ONLY, so this isn't+    /// possible here — prints how to do it with `adom-google` (the Chat API).+    Edit {+        #[arg(long)]+        space: Option<String>,+        /// The message id / name (spaces/.../messages/...).+        message: Option<String>,+    },+    /// Delete a message you already posted. Webhooks are POST-ONLY, so this+    /// isn't possible here — prints how to do it with `adom-google`.+    Delete {+        #[arg(long)]+        space: Option<String>,+        /// The message id / name (spaces/.../messages/...).+        message: Option<String>,+    }, }  #[derive(Serialize, Deserialize, Clone)]@@ -194,6 +210,106 @@ fn wiki_token() -> String {         .to_string() } +/// Mask the secret parts (`key`, `token`) of a Google Chat webhook URL.+fn mask_url(u: &str) -> String {+    let mut s = u.to_string();+    for p in ["key", "token"] {+        let pat = format!("{p}=");+        if let Some(i) = s.find(&pat) {+            let vs = i + pat.len();+            let ve = s[vs..].find('&').map(|j| vs + j).unwrap_or(s.len());+            s.replace_range(vs..ve, "***");+        }+    }+    s+}++const CFG_START: &str =+    "<!-- adom-gchat:config START — auto-generated by `adom-gchat publish`; edits between markers are overwritten -->";+const CFG_END: &str = "<!-- adom-gchat:config END -->";++/// The auto-generated config section (spaces table + masked config) for an org+/// page README. Built from the LOCAL config so URLs can be shown masked even+/// when they are not stored in the page itself.+fn org_config_block(cfg: &WebhookConfig) -> String {+    let mut names: Vec<&String> = cfg.spaces.keys().collect();+    names.sort();+    let mut t = String::new();+    t.push_str(CFG_START);+    t.push_str("\n## Spaces\n\n");+    t.push_str("**Webhooks are per-space** — each Google Chat space has its own incoming webhook, and a webhook can only post to the space it was created in.\n\n");+    t.push_str("| Space | Google Chat space ID | Webhook (masked) | Purpose |\n|---|---|---|---|\n");+    for name in &names {+        let sp = &cfg.spaces[*name];+        let sid = sp.url.split("/spaces/").nth(1)+            .and_then(|s| s.split('/').next()).unwrap_or("—");+        let url = if sp.url.is_empty() {+            "_(set locally via `setup`)_".to_string()+        } else {+            format!("`…/messages?key=***&token=***`")+        };+        let desc = sp.description.as_deref().unwrap_or("");+        t.push_str(&format!("| `{}` | `{}` | {} | {} |\n", name, sid, url, desc));+    }+    t.push_str("\nPost with `adom-gchat send --space <name> \"message\"`.\n\n");+    t.push_str("## Webhook config (`~/.config/gchat-webhooks.json`)\n\n");+    t.push_str("The exact shape `adom-gchat` reads locally. **URLs are masked** — the `key`/`token` are live secrets and are never committed to this git-backed page:\n\n```json\n");+    let mut masked = cfg.clone();+    for sp in masked.spaces.values_mut() {+        if !sp.url.is_empty() {+            sp.url = mask_url(&sp.url);+        }+    }+    t.push_str(&serde_json::to_string_pretty(&masked).unwrap_or_default());+    t.push_str("\n```\n\nTo get the **real** URLs: each space's webhook lives in Google Chat → *Space → Apps & integrations → Webhooks*, or copy from a teammate, then run `adom-gchat setup`.\n");+    t.push_str(CFG_END);+    t+}++/// A full org-page README template, used on first publish. The config block is+/// re-generated on every publish; everything else is yours to edit.+fn org_readme_full(org: &str, block: &str) -> String {+    format!(+        "# adom-gchat — {org} Org Config\n\n\+{org}'s Google Chat setup for the [adom-gchat](/adom/adom-gchat) CLI — auto-imported by teammates when they run `adom-gchat install`.\n\n\+> 🔒 **Internal / org-private.** The public, open-source tool lives at [adom/adom-gchat](/adom/adom-gchat).\n\n\+## How this page is used\n\n\+1. One person ran `adom-gchat publish --org {org}` to create/update this page.\n\+2. Every teammate whose container identity is `{org}` runs `adom-gchat install`, which looks up `adom-gchat-{org}` and imports these spaces automatically — no AI search.\n\n\+{block}\n\n\+## House rules\n\n\+- No emojis in messages — use Google Chat markdown.\n\+- Never leak a container slug or hostname in a message.\n\+- Preview with `--dry-run` before posting to human-facing spaces.\n\n\+_(Yours to edit — this is outside the auto-generated block, so `publish` won't overwrite it.)_\n\n\+## Need more than posting? → adom-google\n\n\+adom-gchat posts via a webhook (post-only, attributed as the bot). For **edit, delete, read, reactions, threads, or posting as yourself**, use [adom-google](/john/adom-google) — full Google Chat API access. Run `adom-gchat edit`/`delete` for the exact commands.\n"+    )+}++/// Printed by `edit`/`delete` and referenced in hints: webhooks are post-only,+/// so these need adom-google (the full Chat API).+fn deeper_features_hint(action: &str) {+    eprintln!("adom-gchat posts via a Google Chat *incoming webhook*, which is POST-ONLY —");+    eprintln!("it cannot {action} messages (or read / react / list them).");+    eprintln!();+    eprintln!("Use `adom-google` for that — it has full Google Chat API access:");+    eprintln!("  adom-google chat send --to <space|email> \"...\"    # post AS YOU (not via webhook)");+    eprintln!("  adom-google chat read --from <space|email>          # read recent messages");+    if action == "edit" {+        eprintln!("  adom-google api -X PATCH 'https://chat.googleapis.com/v1/<message-name>?updateMask=text' \\");+        eprintln!("    -d '{{\"text\":\"updated text\"}}'                 # edit a message");+    } else {+        eprintln!("  adom-google api -X DELETE 'https://chat.googleapis.com/v1/<message-name>'   # delete a message");+    }+    eprintln!();+    eprintln!("  Note: <message-name> looks like spaces/AAA.../messages/BBB..., and Google Chat only");+    eprintln!("  lets you {action} messages posted under the SAME identity. Webhook posts are owned by");+    eprintln!("  the bot, so to {action} later, post with `adom-google chat send` (as yourself) instead.");+    eprintln!();+    eprintln!("  Don't have it? Tell your AI \"install adom-google\" (full Google Workspace CLI).");+}+ fn card_hash(json: &str) -> String {     use std::hash::{Hash, Hasher};     let mut hasher = std::collections::hash_map::DefaultHasher::new();@@ -595,7 +711,7 @@ fn main() -> Result<()> {             std::fs::write(skill_dir.join("SKILL.md"), &skill_content)?;             println!("OK: skill installed to {}", skill_dir.display()); -            let completions = r#"complete -W "send spaces health setup preview install publish" adom-gchat"#;+            let completions = r#"complete -W "send spaces health setup preview install publish edit delete" adom-gchat"#;             let comp_dir = home.join(".bash_completion.d");             std::fs::create_dir_all(&comp_dir)?;             std::fs::write(comp_dir.join("adom-gchat"), completions)?;@@ -701,19 +817,26 @@ fn main() -> Result<()> {                 }             } -            // 1. Ensure the page exists, org-owned, and private.-            let exists = client+            // 1. Ensure the page exists, org-owned, and private. Only create on+            //    an explicit 404 — a transient error (429/5xx) must NOT trigger a+            //    bogus create against an existing slug.+            let exists = match client                 .get(format!("{base}/api/v1/pages/{slug}"))                 .header("User-Agent", ua)                 .header("Authorization", &auth)                 .send()-                .map(|r| r.status().is_success())-                .unwrap_or(false);+            {+                Ok(r) if r.status().is_success() => true,+                Ok(r) if r.status().as_u16() == 404 => false,+                Ok(r) => return Err(anyhow!("wiki returned HTTP {} checking {slug} — try again in a moment", r.status())),+                Err(e) => return Err(anyhow!("can't reach the wiki to check {slug}: {e}")),+            };             if !exists {                 let create = serde_json::json!({                     "type": "app", "slug": slug, "version": "1.0.0",                     "title": format!("adom-gchat — {org_name} Org Config"),                     "brief": format!("{org_name}'s Google Chat config for adom-gchat."),+                    "install": { "binary_name": slug },                 });                 let r = client                     .post(format!("{base}/api/v1/pages"))@@ -749,13 +872,31 @@ fn main() -> Result<()> {             page_json["org_webhooks"] = serde_json::to_value(&pub_cfg)?;             page_json["visibility"] = serde_json::json!("private"); -            // 3. Commit page.json.+            // 3. Auto-generate the masked README (regenerate just the config+            //    block if the page already has one; else write a full template).+            //    Built from the LOCAL cfg so URLs show masked even though the+            //    page stores none.+            let block = org_config_block(&cfg);+            let existing_readme = client+                .get(format!("{base}/api/v1/pages/{slug}/files/README.md"))+                .header("User-Agent", ua).header("Authorization", &auth)+                .send().ok()+                .and_then(|r| if r.status().is_success() { r.text().ok() } else { None })+                .unwrap_or_default();+            let readme = match (existing_readme.find(CFG_START), existing_readme.find(CFG_END)) {+                (Some(s), Some(e)) if e > s => {+                    format!("{}{}{}", &existing_readme[..s], block, &existing_readme[e + CFG_END.len()..])+                }+                _ => org_readme_full(&org_name, &block),+            };++            // 4. Commit page.json + README.md together.             let payload = serde_json::json!({-                "files": [{-                    "path": "page.json",-                    "content": serde_json::to_string_pretty(&page_json)? + "\n",-                }],-                "message": "Publish org gchat config (org_webhooks)",+                "files": [+                    { "path": "page.json", "content": serde_json::to_string_pretty(&page_json)? + "\n" },+                    { "path": "README.md", "content": readme },+                ],+                "message": "Publish org gchat config (org_webhooks + masked README)",             });             let r = client                 .post(format!("{base}/api/v1/pages/{slug}/files"))@@ -778,6 +919,16 @@ fn main() -> Result<()> {             }             println!("HINT: teammates whose org is '{org_name}' auto-import this on `adom-gchat install`.");         }++        Cmd::Edit { .. } => {+            deeper_features_hint("edit");+            std::process::exit(2);+        }++        Cmd::Delete { .. } => {+            deeper_features_hint("delete");+            std::process::exit(2);+        }     }      Ok(())