app
adom-gchat — Google Chat CLI
Public Made by Adomby adom
Post to Google Chat from any Adom container. Webhook-based, org-customizable.
← Commit history
v2.0.2: real publish subcommand, fix dead URLs, secure org auto-import; docs
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(())