app
adom-gchat — Google Chat CLI
Public Made by Adomby adom
Post to Google Chat from any Adom container. Webhook-based, org-customizable.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
mod send;
#[derive(Parser)]
#[command(name = "adom-gchat", version, about = "Post to Google Chat from any Adom container")]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Send a message to a Google Chat space.
Send {
/// Space name (as configured in webhooks.json).
#[arg(long)]
space: String,
/// Thread key for threaded replies.
#[arg(long)]
thread: Option<String>,
/// Path to a Cards v2 JSON file (instead of plain text).
#[arg(long)]
card: Option<PathBuf>,
/// Print the message to stderr instead of posting. No network call.
#[arg(long)]
dry_run: bool,
/// Disable attribution prefix.
#[arg(long)]
no_attribution: bool,
/// Append "via <repo>" to attribution.
#[arg(long)]
with_repo: bool,
/// REQUIRED to actually post. The AI must first run `send` WITHOUT this
/// flag to get a preview, show it to the user, get explicit approval,
/// then re-run WITH this flag. Without it, send prints the preview and
/// exits with code 2. This is a programmatic gate — the CLI refuses to
/// post unless the caller declares a human reviewed the message.
#[arg(long)]
permission_to_post_given: bool,
/// Message text (omit if using --card or piping stdin).
message: Option<String>,
},
/// List configured spaces.
Spaces,
/// Check webhook connectivity for all spaces.
Health,
/// Interactive setup: configure webhook URLs and org identity.
Setup,
/// Preview a Cards v2 card in a Hydrogen webview before posting.
/// Saves a hash so `send --card` can verify the card was previewed.
Preview {
/// Path to a Cards v2 JSON file.
#[arg(long)]
card: PathBuf,
/// Port for the preview server.
#[arg(long, default_value = "9199")]
port: u16,
/// Tab name in Hydrogen.
#[arg(long, default_value = "GChat Card Preview")]
tab_name: String,
},
/// 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,
},
/// 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)]
struct WebhookConfig {
#[serde(default)]
org: Option<String>,
#[serde(default)]
attribution: Option<String>,
#[serde(default)]
spaces: HashMap<String, SpaceEntry>,
}
#[derive(Serialize, Deserialize, Clone)]
struct SpaceEntry {
url: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Serialize, Deserialize, Default)]
struct Identity {
#[serde(default)]
user: Option<String>,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
repo: Option<String>,
#[serde(default)]
org: Option<String>,
}
fn fetch_or_fallback(url: &str, fallback: &str, label: &str) -> String {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build();
let client = match client {
Ok(c) => c,
Err(_) => {
eprintln!("WARN: HTTP client init failed, using bundled {label}");
return fallback.to_string();
}
};
match client.get(url).send() {
Ok(r) if r.status().is_success() => match r.text() {
Ok(body) if body.len() >= 100 => body,
_ => {
eprintln!("WARN: wiki returned short {label} — using bundled fallback");
fallback.to_string()
}
},
_ => {
eprintln!("WARN: wiki unreachable, using bundled {label} (re-run install once network is up)");
fallback.to_string()
}
}
}
fn config_dir() -> PathBuf {
dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))
}
fn load_webhooks() -> Result<WebhookConfig> {
let path = config_dir().join("gchat-webhooks.json");
if !path.exists() {
return Ok(WebhookConfig {
org: None,
attribution: None,
spaces: HashMap::new(),
});
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&data)
.with_context(|| format!("parsing {}", path.display()))
}
fn save_webhooks(cfg: &WebhookConfig) -> Result<()> {
let path = config_dir().join("gchat-webhooks.json");
std::fs::create_dir_all(path.parent().unwrap())?;
let data = serde_json::to_string_pretty(cfg)? + "\n";
std::fs::write(&path, data)?;
Ok(())
}
fn load_identity() -> Identity {
let path = config_dir().join("adom-identity.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|d| serde_json::from_str(&d).ok())
.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()
}
/// 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();
json.trim().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn preview_hash_path() -> PathBuf {
std::env::temp_dir().join("adom-gchat-preview-hash")
}
fn generate_cards_v2_preview(card_json: &str) -> String {
format!(r##"<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>Google Chat Card Preview (strict Cards v2)</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#202124;color:#e8eaed;font-family:'Google Sans',Roboto,Arial,sans-serif;font-size:14px;padding:24px;display:flex;justify-content:center}}
.w{{max-width:600px;width:100%}}
.lbl{{text-align:center;color:#5f6368;font-size:11px;margin-bottom:16px;letter-spacing:.5px}}
.msg{{display:flex;gap:12px}}
.av{{width:36px;height:36px;border-radius:50%;flex-shrink:0}}
.mb{{flex:1;min-width:0}}
.sn{{font-weight:500;color:#8ab4f8;font-size:13px;margin-bottom:4px}}
.cd{{background:#292a2d;border:1px solid #3c4043;border-radius:8px;overflow:hidden}}
.ch{{display:flex;align-items:center;gap:12px;padding:16px;border-bottom:1px solid #3c4043}}
.ch img{{width:40px;height:40px;border-radius:50%}}
.cht{{flex:1}}.cht h3{{font-size:16px;font-weight:500;color:#e8eaed;margin:0}}.cht p{{font-size:13px;color:#9aa0a6;margin:2px 0 0}}
.sc{{border-bottom:1px solid #3c4043}}.sc:last-child{{border-bottom:none}}
.sh{{font-size:13px;font-weight:500;color:#8ab4f8;padding:12px 16px 4px}}
.wg{{padding:4px 16px 8px}}
.wg img{{width:100%;border-radius:4px;margin:4px 0}}
.wt{{color:#bdc1c6;font-size:13px;line-height:1.6}}
.wt a{{color:#8ab4f8;text-decoration:none}}.wt a:hover{{text-decoration:underline}}
.wt b{{color:#e8eaed}}
.bl{{display:flex;gap:8px;padding:8px 16px 12px;flex-wrap:wrap}}
.bl a{{display:inline-block;padding:6px 16px;border:1px solid #8ab4f8;border-radius:4px;color:#8ab4f8;text-decoration:none;font-size:13px;font-weight:500}}
.bl a:hover{{background:rgba(138,180,248,.1)}}
</style>
</head><body>
<div class="w">
<div class="lbl">STRICT Cards v2 PREVIEW — what you see here is what Google Chat will render</div>
<div class="msg" id="m"></div>
</div>
<script>
var J={card_json};
var c=J.cardsV2[0].card,m=document.getElementById('m');
m.innerHTML='<img class="av" src="'+(c.header&&c.header.imageUrl||'')+'">';
var b=document.createElement('div');b.className='mb';
b.innerHTML='<div class="sn">Kel</div>';
var d=document.createElement('div');d.className='cd';
if(c.header)d.innerHTML+='<div class="ch">'+(c.header.imageUrl?'<img src="'+c.header.imageUrl+'">':'')+'<div class="cht"><h3>'+c.header.title+'</h3><p>'+(c.header.subtitle||'')+'</p></div></div>';
(c.sections||[]).forEach(function(s){{
var e=document.createElement('div');e.className='sc';
if(s.header)e.innerHTML+='<div class="sh">'+s.header+'</div>';
(s.widgets||[]).forEach(function(w){{
if(w.image){{var iu=w.image.imageUrl,ol=w.image.onClick&&w.image.onClick.openLink?w.image.onClick.openLink.url:'';e.innerHTML+=ol?'<div class="wg"><a href="'+ol+'" target="_blank" style="display:block;position:relative"><img src="'+iu+'"><span style="position:absolute;bottom:8px;right:8px;background:rgba(0,0,0,.7);color:#8ab4f8;font-size:11px;padding:3px 8px;border-radius:4px">Click to open</span></a></div>':'<div class="wg"><img src="'+iu+'"></div>'}}
if(w.textParagraph)e.innerHTML+='<div class="wg"><div class="wt">'+w.textParagraph.text+'</div></div>';
if(w.decoratedText)e.innerHTML+='<div class="wg"><div class="wt">'+w.decoratedText.text+'</div></div>';
if(w.buttonList){{var h='<div class="bl">';(w.buttonList.buttons||[]).forEach(function(bt){{var u=bt.onClick&&bt.onClick.openLink?bt.onClick.openLink.url:'#';h+='<a href="'+u+'" target="_blank">'+bt.text+'</a>'}});h+='</div>';e.innerHTML+=h}}
}});
d.appendChild(e);
}});
b.appendChild(d);m.appendChild(b);
</script>
</body></html>"##, card_json = card_json)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Cmd::Send {
space,
thread,
card,
dry_run,
no_attribution,
with_repo,
permission_to_post_given,
message,
} => {
let cfg = load_webhooks()?;
let identity = load_identity();
let entry = cfg.spaces.get(&space).ok_or_else(|| {
let available: Vec<_> = cfg.spaces.keys().collect();
anyhow!(
"space '{}' not configured. Available: {:?}\nRun `adom-gchat setup` to add it.",
space,
available
)
})?;
let body = if let Some(card_path) = card {
let card_json = std::fs::read_to_string(&card_path)
.with_context(|| format!("reading card file {}", card_path.display()))?;
if !dry_run {
let hash = card_hash(&card_json);
let saved = std::fs::read_to_string(preview_hash_path()).unwrap_or_default();
if saved.trim() != hash {
eprintln!("ERROR: This card has not been previewed (or was modified after preview).");
eprintln!("HINT: Run `adom-gchat preview --card {}` first.", card_path.display());
std::process::exit(1);
}
}
card_json
} else {
let text = match message {
Some(m) => m,
None => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf.trim().to_string()
}
};
if text.is_empty() {
return Err(anyhow!("no message provided"));
}
let prefix = if no_attribution {
String::new()
} else {
let template = cfg
.attribution
.as_deref()
.unwrap_or("*{org} (on behalf of {user})*");
let user = identity.user.as_deref().unwrap_or("unknown");
let org = cfg
.org
.as_deref()
.or(identity.org.as_deref())
.unwrap_or("Adom");
let repo = identity.repo.as_deref().unwrap_or("");
let mut attr = template
.replace("{user}", user)
.replace("{org}", org)
.replace("{repo}", repo);
if with_repo && !repo.is_empty() {
attr = format!("{attr} via {repo}");
}
format!("{attr} ")
};
let full_text = format!("{prefix}{text}");
serde_json::json!({ "text": full_text }).to_string()
};
if dry_run {
println!("OK: dry-run (not posted)");
println!("SPACE: {space}");
if let Some(ref t) = thread {
println!("THREAD: {t}");
}
println!("BODY: {body}");
println!("HINT: remove --dry-run to post for real");
} else if !permission_to_post_given {
// Gate: show preview and refuse to post without the flag
println!("PREVIEW: the following message will be posted to #{space}:");
println!("---");
println!("{body}");
println!("---");
println!("HINT: show this preview to the user and get explicit approval.");
println!("HINT: once the user says 'yes' / 'send it' / 'post it', re-run with --permission-to-post-given.");
println!("HINT: do NOT pass --permission-to-post-given without actual user confirmation.");
std::process::exit(2);
} else {
send::post_webhook(&entry.url, &body, thread.as_deref())?;
println!("OK: posted to space '{space}'");
if let Some(ref t) = thread {
println!("THREAD: {t}");
}
}
}
Cmd::Spaces => {
let cfg = load_webhooks()?;
if cfg.spaces.is_empty() {
println!("WARN: no spaces configured");
println!("HINT: run `adom-gchat setup` to add a webhook");
return Ok(());
}
let org = cfg.org.as_deref().unwrap_or("(none)");
println!("OK: {count} space(s) configured", count = cfg.spaces.len());
println!("ORG: {org}");
for (name, entry) in &cfg.spaces {
let desc = entry
.description
.as_deref()
.unwrap_or("(no description)");
println!("SPACE: {name} — {desc}");
}
println!("HINT: use `adom-gchat send --space <name> \"message\"` to post");
println!("HINT: use --dry-run to preview without posting");
}
Cmd::Health => {
let cfg = load_webhooks()?;
if cfg.spaces.is_empty() {
println!("WARN: no spaces configured");
println!("HINT: run `adom-gchat setup` to add a webhook");
return Ok(());
}
let client = reqwest::blocking::Client::new();
let mut all_ok = true;
for (name, entry) in &cfg.spaces {
let base_url = entry.url.split('?').next().unwrap_or(&entry.url);
match client
.get(base_url)
.timeout(std::time::Duration::from_secs(5))
.send()
{
Ok(r) if r.status().is_success()
|| r.status().as_u16() == 400
|| r.status().as_u16() == 401 =>
{
println!("OK: {name} (reachable)");
}
Ok(r) => {
println!("FAIL: {name} (HTTP {})", r.status());
all_ok = false;
}
Err(e) => {
println!("FAIL: {name} ({e})");
all_ok = false;
}
}
}
if all_ok {
println!("HINT: all spaces reachable — ready to send");
} else {
println!("HINT: check webhook URLs with `adom-gchat setup`");
std::process::exit(1);
}
}
Cmd::Setup => {
let mut cfg = load_webhooks()?;
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");
println!();
print!("Org name (e.g. acme-robotics) [{}]: ", cfg.org.as_deref().unwrap_or(""));
std::io::Write::flush(&mut std::io::stdout())?;
let mut line = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line)?;
let line = line.trim();
if !line.is_empty() {
cfg.org = Some(line.to_string());
}
print!("Attribution template [{}]: ",
cfg.attribution.as_deref().unwrap_or("*{org} (on behalf of {user})*"));
std::io::Write::flush(&mut std::io::stdout())?;
let mut line = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line)?;
let line = line.trim();
if !line.is_empty() {
cfg.attribution = Some(line.to_string());
}
println!();
println!("Now add your Google Chat spaces. For each space:");
println!(" 1. Open Google Chat > Space settings > Apps & integrations > Webhooks");
println!(" 2. Click 'Add webhook'");
println!(" 3. Bot name: {KEL_BOT_NAME}");
println!(" 4. Avatar URL: {KEL_AVATAR_URL}");
println!(" 5. Click Save, then copy the webhook URL");
println!();
loop {
print!("Space name (e.g. engineering, general — empty to finish): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut name = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut name)?;
let name = name.trim().to_string();
if name.is_empty() {
break;
}
print!(" Paste the webhook URL for '{name}': ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut url = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut url)?;
let url = url.trim().to_string();
if !url.starts_with("https://chat.googleapis.com/") {
eprintln!(" WARN: doesn't look like a Google Chat webhook URL");
}
print!(" Description (optional): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut desc = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut desc)?;
let desc = desc.trim().to_string();
cfg.spaces.insert(
name.clone(),
SpaceEntry {
url,
description: if desc.is_empty() { None } else { Some(desc) },
},
);
println!(" OK: added '{name}'");
}
save_webhooks(&cfg)?;
println!();
println!("OK: saved to {:?}", config_dir().join("gchat-webhooks.json"));
if let Some(first) = cfg.spaces.keys().next() {
println!("HINT: test with `adom-gchat send --space {first} --dry-run \"hello world\"`");
}
}
Cmd::Preview { card, port, tab_name } => {
let card_json = std::fs::read_to_string(&card)
.with_context(|| format!("reading card file {}", card.display()))?;
// Validate it's valid JSON with cardsV2
let parsed: serde_json::Value = serde_json::from_str(&card_json)
.with_context(|| "card file is not valid JSON")?;
if parsed.get("cardsV2").is_none() {
return Err(anyhow!("card JSON must have a top-level 'cardsV2' array"));
}
// Save the hash so send --card can verify
let hash = card_hash(&card_json);
std::fs::write(preview_hash_path(), &hash)?;
// Generate strict Cards v2 preview HTML
let preview_html = generate_cards_v2_preview(&card_json);
// Write preview files
let preview_dir = std::env::temp_dir().join("adom-gchat-preview");
std::fs::create_dir_all(&preview_dir)?;
std::fs::write(preview_dir.join("preview.html"), &preview_html)?;
std::fs::write(preview_dir.join("card.json"), &card_json)?;
// Start a tiny HTTP server
let addr = format!("127.0.0.1:{port}");
eprintln!("OK: preview server on http://{addr}/preview.html");
eprintln!("HASH: {hash}");
eprintln!("Hint: `send --card` will verify this hash before posting to non-kel spaces.");
// Try to open in Hydrogen
let proxy_base = std::env::var("VSCODE_PROXY_URI")
.unwrap_or_default()
.replace("{{port}}", &port.to_string());
if !proxy_base.is_empty() {
let url = format!("{proxy_base}preview.html");
let _ = std::process::Command::new("adom-cli")
.args(["hydrogen", "webview", "open-or-refresh",
"--name", &tab_name, "--url", &url])
.output();
eprintln!("OK: opened in Hydrogen tab '{tab_name}'");
}
// Serve files
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind(&addr)
.with_context(|| format!("binding to {addr}"))?;
for stream in listener.incoming().flatten() {
let mut buf = [0u8; 4096];
let mut reader = std::io::BufReader::new(&stream);
let n = reader.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let path = req.split_whitespace().nth(1).unwrap_or("/");
let (ct, body) = if path.contains("card.json") {
("application/json", card_json.as_bytes())
} else {
("text/html", preview_html.as_bytes())
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
body.len()
);
let mut writer = stream;
let _ = writer.write_all(resp.as_bytes());
let _ = writer.write_all(body);
}
}
Cmd::Install => {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/adom"));
let skill_content = fetch_or_fallback(
"https://wiki.adom.inc/blob/app/adom-gchat/skills/adom-gchat/SKILL.md",
include_str!("../SKILL.md"),
"SKILL.md",
);
let skill_dir = home.join(".claude/skills/adom-gchat");
std::fs::create_dir_all(&skill_dir)?;
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 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)?;
println!("OK: bash completions installed");
// 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 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 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 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();
}
}
}
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 !imported {
println!("HINT: no org gchat config found for '{org_name}' — run `adom-gchat setup` to configure manually");
}
} else {
let existing = load_webhooks()?;
if existing.spaces.is_empty() {
println!("HINT: run `adom-gchat setup` to configure your Google Chat spaces");
} else {
println!("OK: {} space(s) already configured", existing.spaces.len());
}
}
}
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. 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()
{
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"))
.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. 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" },
{ "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"))
.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`.");
}
Cmd::Edit { .. } => {
deeper_features_hint("edit");
std::process::exit(2);
}
Cmd::Delete { .. } => {
deeper_features_hint("delete");
std::process::exit(2);
}
}
Ok(())
}