← Commit history

v2.0.2: real publish subcommand, fix dead URLs, secure org auto-import; docs

John Lauer ·316485b340 ·4mo ago ·parent 9d0a241
6 files changed +254−42
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-gchat"-version = "2.0.1"+version = "2.0.2" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-gchat"-version = "2.0.1"+version = "2.0.2" edition = "2021" description = "Post to Google Chat from any Adom container. Org-customizable." 
README.md+30−1
@@ -60,6 +60,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`. +## Bring it to your whole org++adom-gchat is built for any org — NTX Embedded, Teaazers, Cosmiic, you. One+person configures the spaces, then publishes the config so **every teammate+auto-imports it** with no repeat setup:++```bash+adom-gchat setup                     # configure spaces (first person)+adom-gchat publish --org acme        # publish config to a PRIVATE org page+# teammates:+adom-gchat install                   # auto-imports the org's spaces+```++`publish` writes your **spaces, descriptions, and attribution** to a private+wiki page `adom-gchat-<org>` (visible only to your org). Teammates whose+container identity is in that org pull it automatically on `install`.++**Webhook URLs are secrets and are NOT published by default** — the wiki page is+a git repo. After `install`, each teammate runs `adom-gchat setup` once to paste+the shared webhook URL. (Prefer convenience? `publish --org <org>+--include-webhook-urls` stores the shared URLs on the private page too — only for+a page you trust to stay private.)++**AI auto-discovery:** the installed `adom-gchat` skill triggers on "post to+gchat" / "notify the team" for every org identically — the org's specific spaces+come from the locally-imported config, so nothing org-specific is baked into the+tool.+ ## Meet Kel — the face of your team's chat  <img src="/blob/app/adom-gchat/screenshots/kel-avatar.png" align="right" width="150" alt="Kel">@@ -142,7 +170,8 @@ masters in `kel/original/`. | `adom-gchat health` | Check webhook connectivity | | `adom-gchat setup` | Interactive webhook + org-identity setup | | `adom-gchat preview` | Preview a Cards v2 card in a Hydrogen webview before posting |-| `adom-gchat install` | Deploy skill + bash completions |+| `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) |  ## Security 
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-gchat",-  "version": "2.0.1",+  "version": "2.0.2",   "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+32−1
@@ -45,7 +45,7 @@ adom-gchat setup  1. Open your Google Chat space > Settings > Apps & integrations > Webhooks 2. Create a webhook — name it "Kel", use this avatar URL:-   `https://wiki.adom.inc/blob/app/adom-gchat/screenshots/kel-avatar.png`+   `https://wiki.adom.inc/blob/app/adom-gchat/icons/kel/png/256x256.png` 3. Copy the webhook URL Google gives you 4. Run `adom-gchat setup` and paste the URL when prompted 5. Test: `adom-gchat send --space <name> --dry-run "hello from our org"`@@ -53,6 +53,37 @@ adom-gchat setup  Your webhook URLs stay private to your org in `~/.config/gchat-webhooks.json`. +## Bring it to your whole org (zero re-setup for teammates)++Once one person has configured the spaces, publish the org's config so every+teammate auto-imports it — they never repeat the setup:++```bash+adom-gchat publish --org <your-org>     # e.g. acme-robotics+```++This writes the org's **spaces, descriptions, and attribution** to a *private*+wiki page (`adom-gchat-<org>`, visible only to your org). Then any teammate+whose container identity is in that org just runs:++```bash+adom-gchat install     # auto-imports the org's spaces+```++**Secret webhook URLs are NOT published by default** — the wiki page is a git+repo, and webhook URLs are secrets. So after `install`, each teammate runs+`adom-gchat setup` once to paste the shared webhook URL for each space.++> Prefer convenience over the secret-in-git tradeoff? `adom-gchat publish --org+> <org> --include-webhook-urls` stores the shared URLs on the (private) page too,+> and teammates get a fully-configured setup from `install` alone. Only do this+> for a page you're confident stays private.++**How the AI finds it:** the installed `adom-gchat` skill triggers on "post to+gchat" / "notify the team" etc. for every org identically; the org's specific+spaces come from the auto-imported local config. Nothing org-specific is+hard-coded into the tool.+ ## Attribution  Every message is prefixed with who sent it:
src/main.rs+189−37
@@ -66,6 +66,20 @@ enum Cmd {     },     /// Deploy skill + bash completions.     Install,+    /// Publish this container's gchat config to the org's private wiki page+    /// (slug `adom-gchat-<org>`) so teammates auto-import it on `install`.+    /// Webhook URLs are STRIPPED by default (they're secrets — the wiki repo+    /// is git). Pass --include-webhook-urls to store the shared URLs too.+    Publish {+        /// Org name (defaults to the org from your local config / setup).+        #[arg(long)]+        org: Option<String>,+        /// Also store the shared webhook URLs in the (private) org page.+        /// WARNING: this commits the URLs to the page's git repo. Only do this+        /// for a private, org-scoped page you trust.+        #[arg(long)]+        include_webhook_urls: bool,+    }, }  #[derive(Serialize, Deserialize, Clone)]@@ -158,6 +172,28 @@ fn load_identity() -> Identity {         .unwrap_or_default() } +/// Wiki base URL (override with ADOM_WIKI_BASE for self-hosters).+fn wiki_base() -> String {+    std::env::var("ADOM_WIKI_BASE")+        .ok()+        .filter(|s| !s.trim().is_empty())+        .unwrap_or_else(|| "https://wiki.adom.inc".to_string())+}++/// Bearer token for the wiki: the container Carbon key, else a saved wiki token.+fn wiki_token() -> String {+    if let Ok(t) = std::fs::read_to_string("/var/run/adom/api-key") {+        let t = t.trim().to_string();+        if !t.is_empty() {+            return t;+        }+    }+    std::fs::read_to_string(config_dir().join("adom-wiki/token"))+        .unwrap_or_default()+        .trim()+        .to_string()+}+ fn card_hash(json: &str) -> String {     use std::hash::{Hash, Hasher};     let mut hasher = std::collections::hash_map::DefaultHasher::new();@@ -399,7 +435,7 @@ fn main() -> Result<()> {         Cmd::Setup => {             let mut cfg = load_webhooks()?; -            const KEL_AVATAR_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-gchat/kel-avatar.png";+            const KEL_AVATAR_URL: &str = "https://wiki.adom.inc/blob/app/adom-gchat/icons/kel/png/256x256.png";             const KEL_BOT_NAME: &str = "Kel";              println!("adom-gchat setup");@@ -550,7 +586,7 @@ fn main() -> Result<()> {             let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/adom"));              let skill_content = fetch_or_fallback(-                "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-gchat/SKILL.md",+                "https://wiki.adom.inc/blob/app/adom-gchat/skills/adom-gchat/SKILL.md",                 include_str!("../SKILL.md"),                 "SKILL.md",             );@@ -559,62 +595,69 @@ 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 install" adom-gchat"#;+            let completions = r#"complete -W "send spaces health setup preview install publish" 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)?;             println!("OK: bash completions installed"); -            // Fetch org webhook config from wiki if the user belongs to an org.-            // The wiki API filters by org membership — only org members see-            // their org's page, which has webhook URLs in metadata.org_webhooks.-            let org = load_identity().org;+            // Auto-import the org's gchat config from its private wiki page+            // (slug `adom-gchat-<org>`). The page is org-private, so only org+            // members get a 200 here. We read `org_webhooks` from the repo's+            // page.json. Webhook URLs may be absent (secure default) — in that+            // case we keep any URLs already configured locally and prompt setup.+            let org = load_identity().org.or_else(|| load_webhooks().ok().and_then(|c| c.org));             if let Some(ref org_name) = org {-                let list_url = "https://wiki-ufypy5dpx93o.adom.cloud/api/v1/pages?type=app";-                let target_slug = format!("adom-gchat-{org_name}");-                let wiki_token = std::fs::read_to_string(-                    config_dir().join("adom-wiki/token")-                ).unwrap_or_default().trim().to_string();-+                let url = format!(+                    "{}/api/v1/pages/adom-gchat-{}/files/page.json",+                    wiki_base(), org_name+                );+                let token = wiki_token();                 let client = reqwest::blocking::Client::builder()                     .timeout(std::time::Duration::from_secs(10))                     .build()                     .ok();-                let fetched = client.and_then(|c| {-                    let mut req = c.get(list_url);-                    if !wiki_token.is_empty() {-                        req = req.header("Authorization", format!("Bearer {wiki_token}"));+                let body = client.and_then(|c| {+                    let mut req = c.get(&url).header("User-Agent", "adom-gchat");+                    if !token.is_empty() {+                        req = req.header("Authorization", format!("Bearer {token}"));                     }                     req.send().ok()                 }).and_then(|r| if r.status().is_success() { r.text().ok() } else { None }); -                let mut found = false;-                if let Some(list_json) = fetched {-                    if let Ok(data) = serde_json::from_str::<serde_json::Value>(&list_json) {-                        let pages = data.get("pages").and_then(|p| p.as_array())-                            .or_else(|| data.as_array());-                        if let Some(pages) = pages {-                            for page in pages {-                                if page.get("slug").and_then(|s| s.as_str()) == Some(&target_slug) {-                                    if let Some(meta_str) = page.get("metadata").and_then(|m| m.as_str()) {-                                        if let Ok(meta) = serde_json::from_str::<serde_json::Value>(meta_str) {-                                            if let Some(webhooks) = meta.get("org_webhooks") {-                                                if let Ok(cfg) = serde_json::from_value::<WebhookConfig>(webhooks.clone()) {-                                                    save_webhooks(&cfg)?;-                                                    println!("OK: imported {} webhook(s) from org '{org_name}' wiki config",-                                                        cfg.spaces.len());-                                                    found = true;-                                                }-                                            }+                let mut imported = false;+                if let Some(page_json) = body {+                    if let Ok(pj) = serde_json::from_str::<serde_json::Value>(&page_json) {+                        if let Some(webhooks) = pj.get("org_webhooks") {+                            if let Ok(mut org_cfg) =+                                serde_json::from_value::<WebhookConfig>(webhooks.clone())+                            {+                                // Preserve any locally-configured URLs for spaces+                                // whose URL the org page intentionally omitted.+                                let local = load_webhooks().unwrap_or(WebhookConfig {+                                    org: None, attribution: None, spaces: HashMap::new(),+                                });+                                for (name, sp) in org_cfg.spaces.iter_mut() {+                                    if sp.url.is_empty() {+                                        if let Some(l) = local.spaces.get(name) {+                                            sp.url = l.url.clone();                                         }                                     }-                                    break;                                 }+                                let total = org_cfg.spaces.len();+                                let with_url = org_cfg.spaces.values()+                                    .filter(|s| !s.url.is_empty()).count();+                                save_webhooks(&org_cfg)?;+                                println!("OK: imported {total} space(s) from org '{org_name}' config ({with_url} with webhook URLs)");+                                if with_url < total {+                                    println!("HINT: {} space(s) still need a webhook URL — run `adom-gchat setup` to paste them.", total - with_url);+                                }+                                imported = true;                             }                         }                     }                 }-                if !found {+                if !imported {                     println!("HINT: no org gchat config found for '{org_name}' — run `adom-gchat setup` to configure manually");                 }             } else {@@ -626,6 +669,115 @@ fn main() -> Result<()> {                 }             }         }++        Cmd::Publish { org, include_webhook_urls } => {+            let cfg = load_webhooks()?;+            let org_name = org+                .or_else(|| cfg.org.clone())+                .ok_or_else(|| anyhow!("no org set — pass --org <name> or run `adom-gchat setup`"))?;+            if cfg.spaces.is_empty() {+                return Err(anyhow!("no spaces configured — run `adom-gchat setup` first"));+            }+            let base = wiki_base();+            let token = wiki_token();+            if token.is_empty() {+                return Err(anyhow!(+                    "no wiki token found (looked in /var/run/adom/api-key and ~/.config/adom-wiki/token)"+                ));+            }+            let slug = format!("adom-gchat-{org_name}");+            let auth = format!("Bearer {token}");+            let ua = concat!("adom-gchat/", env!("CARGO_PKG_VERSION"));+            let client = reqwest::blocking::Client::builder()+                .timeout(std::time::Duration::from_secs(20))+                .build()?;++            // Build the publishable config — strip secret URLs unless opted in.+            let mut pub_cfg = cfg.clone();+            pub_cfg.org = Some(org_name.clone());+            if !include_webhook_urls {+                for sp in pub_cfg.spaces.values_mut() {+                    sp.url = String::new();+                }+            }++            // 1. Ensure the page exists, org-owned, and private.+            let exists = 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);+            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."),+                });+                let r = client+                    .post(format!("{base}/api/v1/pages"))+                    .header("User-Agent", ua)+                    .header("Authorization", &auth)+                    .json(&create)+                    .send()?;+                if !r.status().is_success() {+                    return Err(anyhow!("failed to create page {slug}: HTTP {}", r.status()));+                }+                let _ = client+                    .post(format!("{base}/api/v1/pages/{slug}/transfer"))+                    .header("User-Agent", ua).header("Authorization", &auth)+                    .json(&serde_json::json!({ "org": org_name })).send();+                let _ = client+                    .post(format!("{base}/api/v1/pages/{slug}/visibility"))+                    .header("User-Agent", ua).header("Authorization", &auth)+                    .json(&serde_json::json!({ "visibility": "private" })).send();+                println!("OK: created private org page {slug}");+            }++            // 2. Merge org_webhooks into the existing page.json (keep other fields).+            let mut page_json: serde_json::Value = client+                .get(format!("{base}/api/v1/pages/{slug}/files/page.json"))+                .header("User-Agent", ua).header("Authorization", &auth)+                .send().ok()+                .and_then(|r| if r.status().is_success() { r.text().ok() } else { None })+                .and_then(|t| serde_json::from_str(&t).ok())+                .unwrap_or_else(|| serde_json::json!({+                    "slug": slug, "type": "app", "version": "1.0.0",+                    "title": format!("adom-gchat — {org_name} Org Config"),+                }));+            page_json["org_webhooks"] = serde_json::to_value(&pub_cfg)?;+            page_json["visibility"] = serde_json::json!("private");++            // 3. Commit page.json.+            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)",+            });+            let r = client+                .post(format!("{base}/api/v1/pages/{slug}/files"))+                .header("User-Agent", ua).header("Authorization", &auth)+                .json(&payload).send()?;+            if !r.status().is_success() {+                return Err(anyhow!(+                    "failed to write page.json: HTTP {} — {}",+                    r.status(), r.text().unwrap_or_default()+                ));+            }++            let total = pub_cfg.spaces.len();+            println!("OK: published {total} space(s) to {base}/{org_name}/{slug} (private)");+            if include_webhook_urls {+                println!("WARN: webhook URLs are now stored in this page's git repo — keep the page private.");+            } else {+                println!("NOTE: webhook URLs were NOT published (secure default — they're secrets and the page is git-backed).");+                println!("      Teammates run `adom-gchat setup` to paste the shared URL once, or re-run with --include-webhook-urls.");+            }+            println!("HINT: teammates whose org is '{org_name}' auto-import this on `adom-gchat install`.");+        }     }      Ok(())