From 987e532eb2cf72206fb1c796d2518c70be72c8c7 Mon Sep 17 00:00:00 2001 From: Openbot Date: Sun, 12 Apr 2026 22:33:11 +0800 Subject: [PATCH 01/30] feat: add interactive setup wizard Add openab setup command with 5-step interactive wizard: - Discord bot token verification via API - Server and channel selection with guild/channel fetch - Agent configuration with kiro/claude/codex/gemini choices - Session pool settings - Reaction emoji customization New deps: clap, rpassword, atty, unicode-width, ureq Tests: validate_bot_token, validate_channel_id, generate_config, kiro args --- Cargo.lock | 224 +++++++++++++- Cargo.toml | 5 + src/main.rs | 9 + src/setup.rs | 861 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 7c98b754..585d9988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -49,6 +99,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -128,12 +189,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -432,6 +539,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "http" version = "1.4.0" @@ -696,6 +812,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -851,16 +973,25 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openab" -version = "0.6.6" +version = "0.7.1" dependencies = [ "anyhow", + "atty", "base64", + "clap", "image", "rand 0.8.5", "regex", "reqwest", + "rpassword", "serde", "serde_json", "serenity", @@ -868,6 +999,8 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-width", + "ureq", "uuid", ] @@ -1203,6 +1336,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327b72899159dfae8060c51a1f6aebe955245bcd9cc4997eed0f623caea022e4" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1229,6 +1383,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1477,6 +1632,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1909,6 +2070,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1921,6 +2088,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -1946,6 +2131,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.0" @@ -2148,6 +2339,28 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2163,6 +2376,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 77b8ebe2..66a2cf18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,11 @@ uuid = { version = "1", features = ["v4"] } regex = "1" anyhow = "1" rand = "0.8" +clap = { version = "4", features = ["derive"] } +rpassword = "7" +atty = "0.2" +unicode-width = "0.1" +ureq = { version = "2", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } diff --git a/src/main.rs b/src/main.rs index 225bf236..2851d30c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod discord; mod error_display; mod format; mod reactions; +mod setup; mod stt; use serenity::prelude::*; @@ -21,6 +22,14 @@ async fn main() -> anyhow::Result<()> { ) .init(); + // Setup wizard mode + if let Some(arg) = std::env::args().nth(1) { + if arg == "setup" { + setup::run_setup(None)?; + return Ok(()); + } + } + let config_path = std::env::args() .nth(1) .map(PathBuf::from) diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 00000000..496a78dd --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,861 @@ +//! Interactive setup wizard for OpenAB. + +use std::io::{self, Write}; +use std::path::PathBuf; + +// --------------------------------------------------------------------------- +// Color codes (ANSI) +// --------------------------------------------------------------------------- + +const C: Colors = Colors { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", +}; + +struct Colors { + reset: &'static str, + bold: &'static str, + dim: &'static str, + cyan: &'static str, + green: &'static str, + red: &'static str, + yellow: &'static str, + magenta: &'static str, +} + +macro_rules! cprintln { + ($color:expr, $fmt:expr) => {{ + println!("{}{}{}", $color, $fmt, C.reset); + }}; + ($color:expr, $fmt:expr, $($arg:tt)*) => {{ + println!("{}{}{}", $color, format!($fmt, $($arg)*), C.reset); + }}; +} + +// --------------------------------------------------------------------------- +// Input helpers +// --------------------------------------------------------------------------- + +fn is_interactive() -> bool { + atty::is(atty::Stream::Stdout) && atty::is(atty::Stream::Stdin) +} + +fn prompt(prompt_text: &str) -> String { + print!("{}{}: {}", C.yellow, prompt_text, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + input.trim().to_string() +} + +fn prompt_default(prompt_text: &str, default: &str) -> String { + print!("{}{} [{}]: {}", C.yellow, prompt_text, default, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + default.to_string() + } else { + input.to_string() + } +} + +fn prompt_password(prompt_text: &str) -> String { + print!("{}{}: ", C.yellow, prompt_text,); + io::stdout().flush().ok(); + rpassword::read_password().unwrap_or_default() +} + +fn prompt_yes_no(prompt_text: &str, default: bool) -> bool { + let default_str = if default { "Y/n" } else { "y/N" }; + loop { + print!("{}{} [{}]: ", C.yellow, prompt_text, default_str,); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim().to_lowercase(); + if input.is_empty() { + return default; + } + match input.as_str() { + "y" | "yes" => return true, + "n" | "no" => return false, + _ => cprintln!(C.red, "Please enter 'y' or 'n'"), + } + } +} + +fn prompt_choice(prompt_text: &str, choices: &[&str]) -> usize { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, choice) in choices.iter().enumerate() { + println!(" {}. {}", i + 1, choice); + } + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + loop { + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= choices.len() => return n - 1, + _ => { + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + } + } + } +} + +fn prompt_checklist(prompt_text: &str, items: &[&str]) -> Vec { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, item) in items.iter().enumerate() { + println!(" [{}] {}", i + 1, item); + } + println!(); + print!( + "{}Enter numbers separated by commas (e.g. 1,3,5) or press Enter for all: {}", + C.yellow, C.reset + ); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + return (0..items.len()).collect(); + } + input + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|n| *n >= 1 && *n <= items.len()) + .map(|n| n - 1) + .collect() +} + +// --------------------------------------------------------------------------- +// Box drawing helpers +// --------------------------------------------------------------------------- + +fn print_box(lines: &[&str]) { + let width = lines + .iter() + .map(|l| unicode_width::UnicodeWidthStr::width(&**l)) + .max() + .unwrap_or(60); + let width = width.max(60).min(76); + println!(); + cprintln!(C.cyan, "{}", "╔".to_string() + &"═".repeat(width + 2) + "╗"); + for line in lines { + let padded = format!(" {: anyhow::Result<()> { + if token.is_empty() { + anyhow::bail!("Token cannot be empty"); + } + if !token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/') + { + anyhow::bail!( + "Token must only contain ASCII letters, numbers, dash, period, underscore, or slash" + ); + } + Ok(()) +} + +/// Validate agent command +pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { + let valid = ["kiro", "claude", "codex", "gemini"]; + if !valid.contains(&cmd) { + anyhow::bail!("Agent must be one of: {}", valid.join(", ")); + } + Ok(()) +} + +/// Validate channel ID is numeric +pub fn validate_channel_id(id: &str) -> anyhow::Result<()> { + if id.is_empty() { + anyhow::bail!("Channel ID cannot be empty"); + } + if !id.chars().all(|c| c.is_ascii_digit()) { + anyhow::bail!("Channel ID must be numeric only"); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Discord API client +// --------------------------------------------------------------------------- + +struct DiscordClient { + token: String, +} + +impl DiscordClient { + fn new(token: &str) -> Self { + Self { + token: token.to_string(), + } + } + + /// Verify token by fetching bot info + fn verify_token(&self) -> anyhow::Result<(String, String)> { + let resp = ureq::get("https://discord.com/api/v10/users/@me") + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Token verification failed: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct MeResponse { + id: String, + username: String, + } + let me: MeResponse = serde_json::from_value(resp.into_json()?)?; + Ok((me.id, me.username)) + } + + /// Fetch guilds the bot is in + fn fetch_guilds(&self) -> anyhow::Result> { + let resp = ureq::get("https://discord.com/api/v10/users/@me/guilds") + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Failed to fetch guilds: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Guild { + id: String, + name: String, + } + let guilds: Vec = serde_json::from_value(resp.into_json()?)?; + Ok(guilds.into_iter().map(|g| (g.id, g.name)).collect()) + } + + /// Fetch channels in a guild + fn fetch_channels(&self, guild_id: &str) -> anyhow::Result> { + let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id); + let resp = ureq::Agent::new().get(&url) + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Failed to fetch channels: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Channel { + id: String, + #[serde(rename = "type")] + kind: u8, + name: String, + } + let channels: Vec = serde_json::from_value(resp.into_json()?)?; + // type 0 = text channel + Ok(channels + .into_iter() + .filter(|c| c.kind == 0) + .map(|c| (c.id, c.name, guild_id.to_string())) + .collect()) + } +} + +// --------------------------------------------------------------------------- +// Config generation (typed, no string replacement) +// --------------------------------------------------------------------------- + +#[derive(serde::Serialize)] +struct ConfigToml { + discord: DiscordConfigToml, + agent: AgentConfigToml, + pool: PoolConfigToml, + reactions: ReactionsConfigToml, +} + +#[derive(serde::Serialize)] +struct DiscordConfigToml { + bot_token: String, + allowed_channels: Vec, +} + +#[derive(serde::Serialize)] +struct AgentConfigToml { + command: String, + args: Vec, + working_dir: String, +} + +#[derive(serde::Serialize)] +struct PoolConfigToml { + max_sessions: usize, + session_ttl_hours: u64, +} + +#[derive(serde::Serialize)] +struct ReactionsConfigToml { + enabled: bool, + remove_after_reply: bool, + emojis: EmojisToml, + timing: TimingToml, +} + +#[derive(serde::Serialize)] +struct EmojisToml { + queued: String, + thinking: String, + tool: String, + coding: String, + web: String, + done: String, + error: String, +} + +#[derive(serde::Serialize)] +struct TimingToml { + debounce_ms: u64, + stall_soft_ms: u64, + stall_hard_ms: u64, + done_hold_ms: u64, + error_hold_ms: u64, +} + +fn generate_config( + bot_token: &str, + agent_command: &str, + channel_ids: Vec, + working_dir: &str, + max_sessions: usize, + session_ttl_hours: u64, + reactions_enabled: bool, + emojis: &EmojisToml, +) -> String { + let config = ConfigToml { + discord: DiscordConfigToml { + bot_token: bot_token.to_string(), + allowed_channels: channel_ids, + }, + agent: AgentConfigToml { + command: agent_command.to_string(), + args: match agent_command { + "kiro" => vec!["acp".to_string(), "--trust-all-tools".to_string()], + _ => vec![], + }, + working_dir: working_dir.to_string(), + }, + pool: PoolConfigToml { + max_sessions, + session_ttl_hours, + }, + reactions: ReactionsConfigToml { + enabled: reactions_enabled, + remove_after_reply: false, + emojis: EmojisToml { + queued: emojis.queued.clone(), + thinking: emojis.thinking.clone(), + tool: emojis.tool.clone(), + coding: emojis.coding.clone(), + web: emojis.web.clone(), + done: emojis.done.clone(), + error: emojis.error.clone(), + }, + timing: TimingToml { + debounce_ms: 700, + stall_soft_ms: 10_000, + stall_hard_ms: 30_000, + done_hold_ms: 1_500, + error_hold_ms: 2_500, + }, + }, + }; + toml::to_string_pretty(&config).expect("TOML serialization failed") +} + +// --------------------------------------------------------------------------- +// Section 1: Discord Bot Setup Guide +// --------------------------------------------------------------------------- + +fn section_discord_guide() { + print_box(&[ + "Discord Bot Setup Guide", + "", + "1. Go to: https://discord.com/developers/applications", + "2. Click 'New Application' -> name it (e.g. OpenAB)", + "3. Bot -> Reset Token -> COPY the token", + "", + "4. Enable Privileged Gateway Intents:", + " - Message Content Intent", + " - Guild Members Intent", + "", + "5. OAuth2 -> URL Generator:", + " - SCOPES: bot", + " - BOT PERMISSIONS:", + " Send Messages | Embed Links | Attach Files", + " Read Message History | Add Reactions", + " Use Slash Commands", + "", + "6. Visit the generated URL -> add bot to your server", + ]); +} + +// --------------------------------------------------------------------------- +// Section 2: Channel Selection +// --------------------------------------------------------------------------- + +fn section_channels(client: &DiscordClient) -> anyhow::Result> { + println!(); + cprintln!(C.bold, "--- Step 2: Allowed Channels ---"); + println!(); + + print!(" Fetching servers... "); + io::stdout().flush().ok(); + let guilds = client.fetch_guilds()?; + cprintln!(C.green, "OK Found {} server(s)", guilds.len()); + println!(); + + if guilds.is_empty() { + cprintln!( + C.yellow, + " No servers found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let guild_names: Vec<&str> = guilds.iter().map(|(_, n)| n.as_str()).collect(); + let guild_idx = prompt_choice(" Select server:", &guild_names); + let (guild_id, guild_name) = &guilds[guild_idx]; + + print!(" Fetching channels in '{}'... ", guild_name); + io::stdout().flush().ok(); + let channels = client.fetch_channels(guild_id)?; + cprintln!(C.green, "OK Found {} channel(s)", channels.len()); + println!(); + + if channels.is_empty() { + cprintln!( + C.yellow, + " No text channels found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let channel_names: Vec = channels + .iter() + .map(|(_, n, _)| format!("#{}", n)) + .collect(); + let channel_names_refs: Vec<&str> = channel_names + .iter() + .map(|s| s.as_str()) + .collect(); + + let selected = + prompt_checklist(" Select channels (by number):", &channel_names_refs); + let selected_ids: Vec = selected + .iter() + .map(|&i| channels[i].0.clone()) + .collect(); + + println!(); + cprintln!(C.green, " Selected {} channel(s)", selected_ids.len()); + for id in &selected_ids { + if let Some((_, name, _)) = channels.iter().find(|(cid, _, _)| cid == id) { + println!(" * #{}", name); + } else { + println!(" * {}", id); + } + } + println!(); + + Ok(selected_ids) +} + +// --------------------------------------------------------------------------- +// Section 3: Agent Configuration +// --------------------------------------------------------------------------- + +fn section_agent() -> (String, String) { + println!(); + cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); + println!(); + + let choices = ["claude", "kiro", "codex", "gemini"]; + let idx = prompt_choice(" Select agent:", &choices); + let agent = choices[idx]; + + let default_dir = match agent { + "kiro" => "/home/agent", + _ => "/home/node", + }; + let working_dir = prompt_default(" Working directory", default_dir); + + cprintln!( + C.green, + " Agent: {} | Working dir: {}", + agent, + working_dir + ); + println!(); + + (agent.to_string(), working_dir) +} + +// --------------------------------------------------------------------------- +// Section 4: Pool Settings +// --------------------------------------------------------------------------- + +fn section_pool() -> (usize, u64) { + println!(); + cprintln!(C.bold, "--- Step 4: Session Pool ---"); + println!(); + + let max_sessions: usize = prompt_default(" Max sessions", "10") + .parse() + .unwrap_or(10); + let ttl_hours: u64 = prompt_default(" Session TTL (hours)", "24") + .parse() + .unwrap_or(24); + + cprintln!( + C.green, + " Max sessions: {} | TTL: {}h", + max_sessions, + ttl_hours + ); + println!(); + + (max_sessions, ttl_hours) +} + +// --------------------------------------------------------------------------- +// Section 5: Reactions +// --------------------------------------------------------------------------- + +fn section_reactions() -> (bool, EmojisToml) { + println!(); + cprintln!(C.bold, "--- Step 5: Reactions ---"); + println!(); + + let enabled = prompt_yes_no(" Enable reactions?", true); + + let emojis = EmojisToml { + queued: prompt_default(" Emoji: queued", "👀"), + thinking: prompt_default(" Emoji: thinking", "🤔"), + tool: prompt_default(" Emoji: tool", "🔥"), + coding: prompt_default(" Emoji: coding", "👨💻"), + web: prompt_default(" Emoji: web", "⚡"), + done: prompt_default(" Emoji: done", "🆗"), + error: prompt_default(" Emoji: error", "😱"), + }; + + cprintln!( + C.green, + " Reactions: {} | Emojis set", + if enabled { + "enabled" + } else { + "disabled" + } + ); + println!(); + + (enabled, emojis) +} + +// --------------------------------------------------------------------------- +// Preview & Save +// --------------------------------------------------------------------------- + +fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyhow::Result<()> { + println!(); + cprintln!(C.bold, "--- Preview ---"); + println!(); + println!("{}", config_content); + println!(); + + if output_path.exists() { + if !prompt_yes_no(" File exists. Overwrite?", false) { + println!(" Saving cancelled."); + return Ok(()); + } + } + + std::fs::write(output_path, config_content)?; + cprintln!(C.green, "OK config.toml saved to {}", output_path.display()); + println!(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Non-interactive guidance +// --------------------------------------------------------------------------- + +fn print_noninteractive_guide() { + print_box(&[ + "Non-Interactive Mode", + "", + "The interactive wizard requires a terminal.", + "Create config.toml manually, then run:", + "", + " openab run config.toml", + "", + "Config format reference:", + " [discord]", + " bot_token = \"YOUR_BOT_TOKEN\"", + " allowed_channels = [\"CHANNEL_ID\"]", + "", + " [agent]", + " command = \"claude\"", + " args = []", + " working_dir = \"/home/node\"", + "", + " [pool]", + " max_sessions = 10", + " session_ttl_hours = 24", + "", + " [reactions]", + " enabled = true", + " remove_after_reply = false", + " ...", + ]); +} + +// --------------------------------------------------------------------------- +// Main wizard entry point +// --------------------------------------------------------------------------- + +pub fn run_setup(output_path: Option) -> anyhow::Result<()> { + if !is_interactive() { + print_noninteractive_guide(); + return Ok(()); + } + + println!(); + cprintln!( + C.magenta, + "============================================================" + ); + cprintln!( + C.magenta, + " OpenAB Interactive Setup Wizard " + ); + cprintln!( + C.magenta, + "============================================================" + ); + + // Step 1: Discord Guide + Token + section_discord_guide(); + println!(); + let bot_token = prompt_password(" Bot Token (or press Enter to skip)"); + if bot_token.is_empty() { + cprintln!( + C.yellow, + " Skipped. Set bot_token manually in config.toml" + ); + println!(); + cprintln!( + C.green, + " Setup complete! Edit config.toml to add your bot token." + ); + return Ok(()); + } + validate_bot_token(&bot_token)?; + + let client = DiscordClient::new(&bot_token); + print!(" Verifying token with Discord API... "); + io::stdout().flush().ok(); + let (_bot_id, bot_username) = client.verify_token()?; + cprintln!(C.green, "OK Logged in as {}", bot_username); + + // Step 2: Channels + let channel_ids = match section_channels(&client) { + Ok(ids) if !ids.is_empty() => ids, + Ok(_) => { + cprintln!(C.yellow, " No channels selected."); + vec![] + } + Err(e) => { + cprintln!( + C.yellow, + " Channel fetch failed: {}. Enter manually.", + e + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id).map_err(|e| anyhow::anyhow!("{}", e))?; + } + ids + } + }; + + // Step 3: Agent + let (agent, working_dir) = section_agent(); + + // Step 4: Pool + let (max_sessions, ttl_hours) = section_pool(); + + // Step 5: Reactions + let (reactions_enabled, emojis) = section_reactions(); + + // Generate + let config_content = generate_config( + &bot_token, + &agent, + channel_ids, + &working_dir, + max_sessions, + ttl_hours, + reactions_enabled, + &emojis, + ); + + // Output + let output_path = output_path.unwrap_or_else(|| PathBuf::from("config.toml")); + section_preview_and_save(&config_content, &output_path)?; + + cprintln!( + C.green, + " Run with: openab run {}", + output_path.display() + ); + println!(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_bot_token_ok() { + assert!(validate_bot_token("simple_token").is_ok()); + assert!(validate_bot_token("token.with-dashes_123").is_ok()); + assert!(validate_bot_token("sk-ant-ic03-abcd/efgh").is_ok()); + } + + #[test] + fn test_validate_bot_token_reject_invalid() { + assert!(validate_bot_token("").is_err()); + assert!(validate_bot_token("token\nnewline").is_err()); + assert!(validate_bot_token("token\ttab").is_err()); + assert!(validate_bot_token("token with space").is_err()); + } + + #[test] + fn test_validate_agent_command() { + for agent in &["kiro", "claude", "codex", "gemini"] { + assert!(validate_agent_command(agent).is_ok()); + } + assert!(validate_agent_command("invalid").is_err()); + } + + #[test] + fn test_validate_channel_id() { + assert!(validate_channel_id("1492329565824094370").is_ok()); + assert!(validate_channel_id("").is_err()); + assert!(validate_channel_id("abc123").is_err()); + } + + #[test] + fn test_generate_config_contains_sections() { + let emojis = EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }; + let config = generate_config( + "my_token", + "claude", + vec!["123".to_string()], + "/home/node", + 10, + 24, + true, + &emojis, + ); + assert!(config.contains("[discord]")); + assert!(config.contains("[agent]")); + assert!(config.contains("[pool]")); + assert!(config.contains("[reactions]")); + } + + #[test] + fn test_generate_config_kiro_working_dir() { + let emojis = EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }; + let config = generate_config( + "tok", + "kiro", + vec!["ch".to_string()], + "/home/agent", + 10, + 24, + true, + &emojis, + ); + assert!(config.contains(r#"working_dir = "/home/agent""#)); + assert!(config.contains("acp")); + assert!(config.contains("--trust-all-tools")); + } +} From 4d3313868ae98637682a250b6702c354caf9dcaf Mon Sep 17 00:00:00 2001 From: Openbot Date: Sun, 12 Apr 2026 22:33:11 +0800 Subject: [PATCH 02/30] feat: add interactive setup wizard Add openab setup command with 5-step interactive wizard: - Discord bot token verification via API - Server and channel selection with guild/channel fetch - Agent configuration with kiro/claude/codex/gemini choices - Session pool settings - Reaction emoji customization New deps: clap, rpassword, atty, unicode-width, ureq Tests: validate_bot_token, validate_channel_id, generate_config, kiro args --- Cargo.lock | 224 +++++++++++++- Cargo.toml | 5 + src/main.rs | 9 + src/setup.rs | 861 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 7c98b754..585d9988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -49,6 +99,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -128,12 +189,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -432,6 +539,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "http" version = "1.4.0" @@ -696,6 +812,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -851,16 +973,25 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openab" -version = "0.6.6" +version = "0.7.1" dependencies = [ "anyhow", + "atty", "base64", + "clap", "image", "rand 0.8.5", "regex", "reqwest", + "rpassword", "serde", "serde_json", "serenity", @@ -868,6 +999,8 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-width", + "ureq", "uuid", ] @@ -1203,6 +1336,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327b72899159dfae8060c51a1f6aebe955245bcd9cc4997eed0f623caea022e4" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1229,6 +1383,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1477,6 +1632,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1909,6 +2070,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1921,6 +2088,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -1946,6 +2131,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.0" @@ -2148,6 +2339,28 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2163,6 +2376,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index c4faf36d..980abdbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,11 @@ uuid = { version = "1", features = ["v4"] } regex = "1" anyhow = "1" rand = "0.8" +clap = { version = "4", features = ["derive"] } +rpassword = "7" +atty = "0.2" +unicode-width = "0.1" +ureq = { version = "2", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } diff --git a/src/main.rs b/src/main.rs index 7ce135ec..0b945007 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod error_display; mod format; mod markdown; mod reactions; +mod setup; mod stt; use serenity::prelude::*; @@ -22,6 +23,14 @@ async fn main() -> anyhow::Result<()> { ) .init(); + // Setup wizard mode + if let Some(arg) = std::env::args().nth(1) { + if arg == "setup" { + setup::run_setup(None)?; + return Ok(()); + } + } + let config_path = std::env::args() .nth(1) .map(PathBuf::from) diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 00000000..496a78dd --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,861 @@ +//! Interactive setup wizard for OpenAB. + +use std::io::{self, Write}; +use std::path::PathBuf; + +// --------------------------------------------------------------------------- +// Color codes (ANSI) +// --------------------------------------------------------------------------- + +const C: Colors = Colors { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", +}; + +struct Colors { + reset: &'static str, + bold: &'static str, + dim: &'static str, + cyan: &'static str, + green: &'static str, + red: &'static str, + yellow: &'static str, + magenta: &'static str, +} + +macro_rules! cprintln { + ($color:expr, $fmt:expr) => {{ + println!("{}{}{}", $color, $fmt, C.reset); + }}; + ($color:expr, $fmt:expr, $($arg:tt)*) => {{ + println!("{}{}{}", $color, format!($fmt, $($arg)*), C.reset); + }}; +} + +// --------------------------------------------------------------------------- +// Input helpers +// --------------------------------------------------------------------------- + +fn is_interactive() -> bool { + atty::is(atty::Stream::Stdout) && atty::is(atty::Stream::Stdin) +} + +fn prompt(prompt_text: &str) -> String { + print!("{}{}: {}", C.yellow, prompt_text, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + input.trim().to_string() +} + +fn prompt_default(prompt_text: &str, default: &str) -> String { + print!("{}{} [{}]: {}", C.yellow, prompt_text, default, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + default.to_string() + } else { + input.to_string() + } +} + +fn prompt_password(prompt_text: &str) -> String { + print!("{}{}: ", C.yellow, prompt_text,); + io::stdout().flush().ok(); + rpassword::read_password().unwrap_or_default() +} + +fn prompt_yes_no(prompt_text: &str, default: bool) -> bool { + let default_str = if default { "Y/n" } else { "y/N" }; + loop { + print!("{}{} [{}]: ", C.yellow, prompt_text, default_str,); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim().to_lowercase(); + if input.is_empty() { + return default; + } + match input.as_str() { + "y" | "yes" => return true, + "n" | "no" => return false, + _ => cprintln!(C.red, "Please enter 'y' or 'n'"), + } + } +} + +fn prompt_choice(prompt_text: &str, choices: &[&str]) -> usize { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, choice) in choices.iter().enumerate() { + println!(" {}. {}", i + 1, choice); + } + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + loop { + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= choices.len() => return n - 1, + _ => { + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + } + } + } +} + +fn prompt_checklist(prompt_text: &str, items: &[&str]) -> Vec { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, item) in items.iter().enumerate() { + println!(" [{}] {}", i + 1, item); + } + println!(); + print!( + "{}Enter numbers separated by commas (e.g. 1,3,5) or press Enter for all: {}", + C.yellow, C.reset + ); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + return (0..items.len()).collect(); + } + input + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|n| *n >= 1 && *n <= items.len()) + .map(|n| n - 1) + .collect() +} + +// --------------------------------------------------------------------------- +// Box drawing helpers +// --------------------------------------------------------------------------- + +fn print_box(lines: &[&str]) { + let width = lines + .iter() + .map(|l| unicode_width::UnicodeWidthStr::width(&**l)) + .max() + .unwrap_or(60); + let width = width.max(60).min(76); + println!(); + cprintln!(C.cyan, "{}", "╔".to_string() + &"═".repeat(width + 2) + "╗"); + for line in lines { + let padded = format!(" {: anyhow::Result<()> { + if token.is_empty() { + anyhow::bail!("Token cannot be empty"); + } + if !token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/') + { + anyhow::bail!( + "Token must only contain ASCII letters, numbers, dash, period, underscore, or slash" + ); + } + Ok(()) +} + +/// Validate agent command +pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { + let valid = ["kiro", "claude", "codex", "gemini"]; + if !valid.contains(&cmd) { + anyhow::bail!("Agent must be one of: {}", valid.join(", ")); + } + Ok(()) +} + +/// Validate channel ID is numeric +pub fn validate_channel_id(id: &str) -> anyhow::Result<()> { + if id.is_empty() { + anyhow::bail!("Channel ID cannot be empty"); + } + if !id.chars().all(|c| c.is_ascii_digit()) { + anyhow::bail!("Channel ID must be numeric only"); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Discord API client +// --------------------------------------------------------------------------- + +struct DiscordClient { + token: String, +} + +impl DiscordClient { + fn new(token: &str) -> Self { + Self { + token: token.to_string(), + } + } + + /// Verify token by fetching bot info + fn verify_token(&self) -> anyhow::Result<(String, String)> { + let resp = ureq::get("https://discord.com/api/v10/users/@me") + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Token verification failed: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct MeResponse { + id: String, + username: String, + } + let me: MeResponse = serde_json::from_value(resp.into_json()?)?; + Ok((me.id, me.username)) + } + + /// Fetch guilds the bot is in + fn fetch_guilds(&self) -> anyhow::Result> { + let resp = ureq::get("https://discord.com/api/v10/users/@me/guilds") + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Failed to fetch guilds: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Guild { + id: String, + name: String, + } + let guilds: Vec = serde_json::from_value(resp.into_json()?)?; + Ok(guilds.into_iter().map(|g| (g.id, g.name)).collect()) + } + + /// Fetch channels in a guild + fn fetch_channels(&self, guild_id: &str) -> anyhow::Result> { + let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id); + let resp = ureq::Agent::new().get(&url) + .set("Authorization", format!("Bot {}", self.token).as_str()) + .set("User-Agent", "OpenAB setup wizard") + .call()?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("Failed to fetch channels: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Channel { + id: String, + #[serde(rename = "type")] + kind: u8, + name: String, + } + let channels: Vec = serde_json::from_value(resp.into_json()?)?; + // type 0 = text channel + Ok(channels + .into_iter() + .filter(|c| c.kind == 0) + .map(|c| (c.id, c.name, guild_id.to_string())) + .collect()) + } +} + +// --------------------------------------------------------------------------- +// Config generation (typed, no string replacement) +// --------------------------------------------------------------------------- + +#[derive(serde::Serialize)] +struct ConfigToml { + discord: DiscordConfigToml, + agent: AgentConfigToml, + pool: PoolConfigToml, + reactions: ReactionsConfigToml, +} + +#[derive(serde::Serialize)] +struct DiscordConfigToml { + bot_token: String, + allowed_channels: Vec, +} + +#[derive(serde::Serialize)] +struct AgentConfigToml { + command: String, + args: Vec, + working_dir: String, +} + +#[derive(serde::Serialize)] +struct PoolConfigToml { + max_sessions: usize, + session_ttl_hours: u64, +} + +#[derive(serde::Serialize)] +struct ReactionsConfigToml { + enabled: bool, + remove_after_reply: bool, + emojis: EmojisToml, + timing: TimingToml, +} + +#[derive(serde::Serialize)] +struct EmojisToml { + queued: String, + thinking: String, + tool: String, + coding: String, + web: String, + done: String, + error: String, +} + +#[derive(serde::Serialize)] +struct TimingToml { + debounce_ms: u64, + stall_soft_ms: u64, + stall_hard_ms: u64, + done_hold_ms: u64, + error_hold_ms: u64, +} + +fn generate_config( + bot_token: &str, + agent_command: &str, + channel_ids: Vec, + working_dir: &str, + max_sessions: usize, + session_ttl_hours: u64, + reactions_enabled: bool, + emojis: &EmojisToml, +) -> String { + let config = ConfigToml { + discord: DiscordConfigToml { + bot_token: bot_token.to_string(), + allowed_channels: channel_ids, + }, + agent: AgentConfigToml { + command: agent_command.to_string(), + args: match agent_command { + "kiro" => vec!["acp".to_string(), "--trust-all-tools".to_string()], + _ => vec![], + }, + working_dir: working_dir.to_string(), + }, + pool: PoolConfigToml { + max_sessions, + session_ttl_hours, + }, + reactions: ReactionsConfigToml { + enabled: reactions_enabled, + remove_after_reply: false, + emojis: EmojisToml { + queued: emojis.queued.clone(), + thinking: emojis.thinking.clone(), + tool: emojis.tool.clone(), + coding: emojis.coding.clone(), + web: emojis.web.clone(), + done: emojis.done.clone(), + error: emojis.error.clone(), + }, + timing: TimingToml { + debounce_ms: 700, + stall_soft_ms: 10_000, + stall_hard_ms: 30_000, + done_hold_ms: 1_500, + error_hold_ms: 2_500, + }, + }, + }; + toml::to_string_pretty(&config).expect("TOML serialization failed") +} + +// --------------------------------------------------------------------------- +// Section 1: Discord Bot Setup Guide +// --------------------------------------------------------------------------- + +fn section_discord_guide() { + print_box(&[ + "Discord Bot Setup Guide", + "", + "1. Go to: https://discord.com/developers/applications", + "2. Click 'New Application' -> name it (e.g. OpenAB)", + "3. Bot -> Reset Token -> COPY the token", + "", + "4. Enable Privileged Gateway Intents:", + " - Message Content Intent", + " - Guild Members Intent", + "", + "5. OAuth2 -> URL Generator:", + " - SCOPES: bot", + " - BOT PERMISSIONS:", + " Send Messages | Embed Links | Attach Files", + " Read Message History | Add Reactions", + " Use Slash Commands", + "", + "6. Visit the generated URL -> add bot to your server", + ]); +} + +// --------------------------------------------------------------------------- +// Section 2: Channel Selection +// --------------------------------------------------------------------------- + +fn section_channels(client: &DiscordClient) -> anyhow::Result> { + println!(); + cprintln!(C.bold, "--- Step 2: Allowed Channels ---"); + println!(); + + print!(" Fetching servers... "); + io::stdout().flush().ok(); + let guilds = client.fetch_guilds()?; + cprintln!(C.green, "OK Found {} server(s)", guilds.len()); + println!(); + + if guilds.is_empty() { + cprintln!( + C.yellow, + " No servers found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let guild_names: Vec<&str> = guilds.iter().map(|(_, n)| n.as_str()).collect(); + let guild_idx = prompt_choice(" Select server:", &guild_names); + let (guild_id, guild_name) = &guilds[guild_idx]; + + print!(" Fetching channels in '{}'... ", guild_name); + io::stdout().flush().ok(); + let channels = client.fetch_channels(guild_id)?; + cprintln!(C.green, "OK Found {} channel(s)", channels.len()); + println!(); + + if channels.is_empty() { + cprintln!( + C.yellow, + " No text channels found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let channel_names: Vec = channels + .iter() + .map(|(_, n, _)| format!("#{}", n)) + .collect(); + let channel_names_refs: Vec<&str> = channel_names + .iter() + .map(|s| s.as_str()) + .collect(); + + let selected = + prompt_checklist(" Select channels (by number):", &channel_names_refs); + let selected_ids: Vec = selected + .iter() + .map(|&i| channels[i].0.clone()) + .collect(); + + println!(); + cprintln!(C.green, " Selected {} channel(s)", selected_ids.len()); + for id in &selected_ids { + if let Some((_, name, _)) = channels.iter().find(|(cid, _, _)| cid == id) { + println!(" * #{}", name); + } else { + println!(" * {}", id); + } + } + println!(); + + Ok(selected_ids) +} + +// --------------------------------------------------------------------------- +// Section 3: Agent Configuration +// --------------------------------------------------------------------------- + +fn section_agent() -> (String, String) { + println!(); + cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); + println!(); + + let choices = ["claude", "kiro", "codex", "gemini"]; + let idx = prompt_choice(" Select agent:", &choices); + let agent = choices[idx]; + + let default_dir = match agent { + "kiro" => "/home/agent", + _ => "/home/node", + }; + let working_dir = prompt_default(" Working directory", default_dir); + + cprintln!( + C.green, + " Agent: {} | Working dir: {}", + agent, + working_dir + ); + println!(); + + (agent.to_string(), working_dir) +} + +// --------------------------------------------------------------------------- +// Section 4: Pool Settings +// --------------------------------------------------------------------------- + +fn section_pool() -> (usize, u64) { + println!(); + cprintln!(C.bold, "--- Step 4: Session Pool ---"); + println!(); + + let max_sessions: usize = prompt_default(" Max sessions", "10") + .parse() + .unwrap_or(10); + let ttl_hours: u64 = prompt_default(" Session TTL (hours)", "24") + .parse() + .unwrap_or(24); + + cprintln!( + C.green, + " Max sessions: {} | TTL: {}h", + max_sessions, + ttl_hours + ); + println!(); + + (max_sessions, ttl_hours) +} + +// --------------------------------------------------------------------------- +// Section 5: Reactions +// --------------------------------------------------------------------------- + +fn section_reactions() -> (bool, EmojisToml) { + println!(); + cprintln!(C.bold, "--- Step 5: Reactions ---"); + println!(); + + let enabled = prompt_yes_no(" Enable reactions?", true); + + let emojis = EmojisToml { + queued: prompt_default(" Emoji: queued", "👀"), + thinking: prompt_default(" Emoji: thinking", "🤔"), + tool: prompt_default(" Emoji: tool", "🔥"), + coding: prompt_default(" Emoji: coding", "👨💻"), + web: prompt_default(" Emoji: web", "⚡"), + done: prompt_default(" Emoji: done", "🆗"), + error: prompt_default(" Emoji: error", "😱"), + }; + + cprintln!( + C.green, + " Reactions: {} | Emojis set", + if enabled { + "enabled" + } else { + "disabled" + } + ); + println!(); + + (enabled, emojis) +} + +// --------------------------------------------------------------------------- +// Preview & Save +// --------------------------------------------------------------------------- + +fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyhow::Result<()> { + println!(); + cprintln!(C.bold, "--- Preview ---"); + println!(); + println!("{}", config_content); + println!(); + + if output_path.exists() { + if !prompt_yes_no(" File exists. Overwrite?", false) { + println!(" Saving cancelled."); + return Ok(()); + } + } + + std::fs::write(output_path, config_content)?; + cprintln!(C.green, "OK config.toml saved to {}", output_path.display()); + println!(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Non-interactive guidance +// --------------------------------------------------------------------------- + +fn print_noninteractive_guide() { + print_box(&[ + "Non-Interactive Mode", + "", + "The interactive wizard requires a terminal.", + "Create config.toml manually, then run:", + "", + " openab run config.toml", + "", + "Config format reference:", + " [discord]", + " bot_token = \"YOUR_BOT_TOKEN\"", + " allowed_channels = [\"CHANNEL_ID\"]", + "", + " [agent]", + " command = \"claude\"", + " args = []", + " working_dir = \"/home/node\"", + "", + " [pool]", + " max_sessions = 10", + " session_ttl_hours = 24", + "", + " [reactions]", + " enabled = true", + " remove_after_reply = false", + " ...", + ]); +} + +// --------------------------------------------------------------------------- +// Main wizard entry point +// --------------------------------------------------------------------------- + +pub fn run_setup(output_path: Option) -> anyhow::Result<()> { + if !is_interactive() { + print_noninteractive_guide(); + return Ok(()); + } + + println!(); + cprintln!( + C.magenta, + "============================================================" + ); + cprintln!( + C.magenta, + " OpenAB Interactive Setup Wizard " + ); + cprintln!( + C.magenta, + "============================================================" + ); + + // Step 1: Discord Guide + Token + section_discord_guide(); + println!(); + let bot_token = prompt_password(" Bot Token (or press Enter to skip)"); + if bot_token.is_empty() { + cprintln!( + C.yellow, + " Skipped. Set bot_token manually in config.toml" + ); + println!(); + cprintln!( + C.green, + " Setup complete! Edit config.toml to add your bot token." + ); + return Ok(()); + } + validate_bot_token(&bot_token)?; + + let client = DiscordClient::new(&bot_token); + print!(" Verifying token with Discord API... "); + io::stdout().flush().ok(); + let (_bot_id, bot_username) = client.verify_token()?; + cprintln!(C.green, "OK Logged in as {}", bot_username); + + // Step 2: Channels + let channel_ids = match section_channels(&client) { + Ok(ids) if !ids.is_empty() => ids, + Ok(_) => { + cprintln!(C.yellow, " No channels selected."); + vec![] + } + Err(e) => { + cprintln!( + C.yellow, + " Channel fetch failed: {}. Enter manually.", + e + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id).map_err(|e| anyhow::anyhow!("{}", e))?; + } + ids + } + }; + + // Step 3: Agent + let (agent, working_dir) = section_agent(); + + // Step 4: Pool + let (max_sessions, ttl_hours) = section_pool(); + + // Step 5: Reactions + let (reactions_enabled, emojis) = section_reactions(); + + // Generate + let config_content = generate_config( + &bot_token, + &agent, + channel_ids, + &working_dir, + max_sessions, + ttl_hours, + reactions_enabled, + &emojis, + ); + + // Output + let output_path = output_path.unwrap_or_else(|| PathBuf::from("config.toml")); + section_preview_and_save(&config_content, &output_path)?; + + cprintln!( + C.green, + " Run with: openab run {}", + output_path.display() + ); + println!(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_bot_token_ok() { + assert!(validate_bot_token("simple_token").is_ok()); + assert!(validate_bot_token("token.with-dashes_123").is_ok()); + assert!(validate_bot_token("sk-ant-ic03-abcd/efgh").is_ok()); + } + + #[test] + fn test_validate_bot_token_reject_invalid() { + assert!(validate_bot_token("").is_err()); + assert!(validate_bot_token("token\nnewline").is_err()); + assert!(validate_bot_token("token\ttab").is_err()); + assert!(validate_bot_token("token with space").is_err()); + } + + #[test] + fn test_validate_agent_command() { + for agent in &["kiro", "claude", "codex", "gemini"] { + assert!(validate_agent_command(agent).is_ok()); + } + assert!(validate_agent_command("invalid").is_err()); + } + + #[test] + fn test_validate_channel_id() { + assert!(validate_channel_id("1492329565824094370").is_ok()); + assert!(validate_channel_id("").is_err()); + assert!(validate_channel_id("abc123").is_err()); + } + + #[test] + fn test_generate_config_contains_sections() { + let emojis = EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }; + let config = generate_config( + "my_token", + "claude", + vec!["123".to_string()], + "/home/node", + 10, + 24, + true, + &emojis, + ); + assert!(config.contains("[discord]")); + assert!(config.contains("[agent]")); + assert!(config.contains("[pool]")); + assert!(config.contains("[reactions]")); + } + + #[test] + fn test_generate_config_kiro_working_dir() { + let emojis = EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }; + let config = generate_config( + "tok", + "kiro", + vec!["ch".to_string()], + "/home/agent", + 10, + 24, + true, + &emojis, + ); + assert!(config.contains(r#"working_dir = "/home/agent""#)); + assert!(config.contains("acp")); + assert!(config.contains("--trust-all-tools")); + } +} From 3dec4c8cd471394310eb28feeff66a7a0f0a9613 Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 10:59:03 +0800 Subject: [PATCH 03/30] feat(setup): add agent installation guide to agent selection step Show npm install commands for each agent (claude, kiro, codex, gemini) so users know how to install the agent before selecting it. --- src/setup.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/setup.rs b/src/setup.rs index 496a78dd..e5408877 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -517,6 +517,19 @@ fn section_agent() -> (String, String) { cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); println!(); + // Show agent installation guide + print_box(&[ + "Agent Installation Guide", + "", + "claude: npm install -g @anthropic-ai/claude-code", + "kiro: npm install -g @koryhutchison/kiro-cli", + "codex: npm install -g openai-codex (requires OpenAI API key)", + "gemini: npm install -g @google/gemini-cli", + "", + "Make sure the agent is in your PATH before continuing.", + ]); + println!(); + let choices = ["claude", "kiro", "codex", "gemini"]; let idx = prompt_choice(" Select agent:", &choices); let agent = choices[idx]; From 2ffafbc22161268d14d1947889727ed0bd91a3de Mon Sep 17 00:00:00 2001 From: Sammy Lin Date: Mon, 13 Apr 2026 14:04:45 +0800 Subject: [PATCH 04/30] feat(setup): refine wizard with correct agent commands and deployment-aware guidance - Map agent choice to actual binary (kiro-cli/claude-agent-acp/codex-acp/gemini) per README - Add deployment target prompt (Local vs Docker/k8s) to pick sensible working_dir default - Hardcode reactions defaults instead of prompting; keep [reactions] sections in output - Mask bot_token in preview; still write real token to config.toml - Show per-agent next steps (install/auth/run) tailored to deployment target - Local dev uses `cargo run -- run `; Docker path points to Helm + kubectl exec Co-Authored-By: Claude Opus 4.6 (1M context) --- src/setup.rs | 214 +++++++++++++++++++++++++++++---------------------- 1 file changed, 121 insertions(+), 93 deletions(-) diff --git a/src/setup.rs b/src/setup.rs index e5408877..42953419 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -348,37 +348,44 @@ fn generate_config( working_dir: &str, max_sessions: usize, session_ttl_hours: u64, - reactions_enabled: bool, - emojis: &EmojisToml, ) -> String { let config = ConfigToml { discord: DiscordConfigToml { bot_token: bot_token.to_string(), allowed_channels: channel_ids, }, - agent: AgentConfigToml { - command: agent_command.to_string(), - args: match agent_command { - "kiro" => vec!["acp".to_string(), "--trust-all-tools".to_string()], - _ => vec![], - }, - working_dir: working_dir.to_string(), + agent: { + let (command, args): (&str, Vec) = match agent_command { + "kiro" => ( + "kiro-cli", + vec!["acp".into(), "--trust-all-tools".into()], + ), + "claude" => ("claude-agent-acp", vec![]), + "codex" => ("codex-acp", vec![]), + "gemini" => ("gemini", vec!["--acp".into()]), + other => (other, vec![]), + }; + AgentConfigToml { + command: command.to_string(), + args, + working_dir: working_dir.to_string(), + } }, pool: PoolConfigToml { max_sessions, session_ttl_hours, }, reactions: ReactionsConfigToml { - enabled: reactions_enabled, + enabled: true, remove_after_reply: false, emojis: EmojisToml { - queued: emojis.queued.clone(), - thinking: emojis.thinking.clone(), - tool: emojis.tool.clone(), - coding: emojis.coding.clone(), - web: emojis.web.clone(), - done: emojis.done.clone(), - error: emojis.error.clone(), + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), }, timing: TimingToml { debounce_ms: 700, @@ -512,7 +519,7 @@ fn section_channels(client: &DiscordClient) -> anyhow::Result> { // Section 3: Agent Configuration // --------------------------------------------------------------------------- -fn section_agent() -> (String, String) { +fn section_agent() -> (String, String, bool) { println!(); cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); println!(); @@ -534,10 +541,11 @@ fn section_agent() -> (String, String) { let idx = prompt_choice(" Select agent:", &choices); let agent = choices[idx]; - let default_dir = match agent { - "kiro" => "/home/agent", - _ => "/home/node", - }; + let deploy_choices = ["Local (current directory)", "Docker / k8s (/home/agent)"]; + let deploy_idx = prompt_choice(" Deployment target:", &deploy_choices); + let is_local = deploy_idx == 0; + let default_dir = if is_local { "." } else { "/home/agent" }; + let working_dir = prompt_default(" Working directory", default_dir); cprintln!( @@ -548,7 +556,7 @@ fn section_agent() -> (String, String) { ); println!(); - (agent.to_string(), working_dir) + (agent.to_string(), working_dir, is_local) } // --------------------------------------------------------------------------- @@ -582,46 +590,29 @@ fn section_pool() -> (usize, u64) { // Section 5: Reactions // --------------------------------------------------------------------------- -fn section_reactions() -> (bool, EmojisToml) { - println!(); - cprintln!(C.bold, "--- Step 5: Reactions ---"); - println!(); - - let enabled = prompt_yes_no(" Enable reactions?", true); - - let emojis = EmojisToml { - queued: prompt_default(" Emoji: queued", "👀"), - thinking: prompt_default(" Emoji: thinking", "🤔"), - tool: prompt_default(" Emoji: tool", "🔥"), - coding: prompt_default(" Emoji: coding", "👨💻"), - web: prompt_default(" Emoji: web", "⚡"), - done: prompt_default(" Emoji: done", "🆗"), - error: prompt_default(" Emoji: error", "😱"), - }; - - cprintln!( - C.green, - " Reactions: {} | Emojis set", - if enabled { - "enabled" - } else { - "disabled" - } - ); - println!(); - - (enabled, emojis) -} - // --------------------------------------------------------------------------- // Preview & Save // --------------------------------------------------------------------------- +fn mask_bot_token(config: &str) -> String { + config + .lines() + .map(|line| { + if line.trim_start().starts_with("bot_token") { + "bot_token = \"***\"".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyhow::Result<()> { println!(); cprintln!(C.bold, "--- Preview ---"); println!(); - println!("{}", config_content); + println!("{}", mask_bot_token(config_content)); println!(); if output_path.exists() { @@ -657,9 +648,9 @@ fn print_noninteractive_guide() { " allowed_channels = [\"CHANNEL_ID\"]", "", " [agent]", - " command = \"claude\"", - " args = []", - " working_dir = \"/home/node\"", + " command = \"kiro-cli\"", + " args = [\"acp\", \"--trust-all-tools\"]", + " working_dir = \"/home/agent\"", "", " [pool]", " max_sessions = 10", @@ -747,14 +738,11 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { }; // Step 3: Agent - let (agent, working_dir) = section_agent(); + let (agent, working_dir, is_local) = section_agent(); // Step 4: Pool let (max_sessions, ttl_hours) = section_pool(); - // Step 5: Reactions - let (reactions_enabled, emojis) = section_reactions(); - // Generate let config_content = generate_config( &bot_token, @@ -763,24 +751,84 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { &working_dir, max_sessions, ttl_hours, - reactions_enabled, - &emojis, ); // Output let output_path = output_path.unwrap_or_else(|| PathBuf::from("config.toml")); section_preview_and_save(&config_content, &output_path)?; - cprintln!( - C.green, - " Run with: openab run {}", - output_path.display() - ); - println!(); + print_next_steps(&agent, &output_path, is_local); Ok(()) } +fn print_next_steps(agent: &str, output_path: &PathBuf, is_local: bool) { + println!(); + cprintln!(C.bold, "--- Next Steps ---"); + println!(); + + if is_local { + match agent { + "kiro" => { + cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" kiro-cli login --use-device-flow"); + } + "claude" => { + cprintln!(C.cyan, " 1. Install Claude Code + ACP adapter:"); + println!(" npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" claude setup-token"); + } + "codex" => { + cprintln!(C.cyan, " 1. Install Codex CLI + ACP adapter:"); + println!(" npm install -g @openai/codex @zed-industries/codex-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" codex login --device-auth"); + } + "gemini" => { + cprintln!(C.cyan, " 1. Install Gemini CLI:"); + println!(" npm install -g @google/gemini-cli"); + cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); + } + _ => {} + } + + println!(); + cprintln!(C.green, " 3. Run the bot:"); + println!(" cargo run -- run {}", output_path.display()); + } else { + cprintln!( + C.cyan, + " Docker image already bundles the agent CLI and ACP adapter." + ); + println!(); + cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); + println!(" helm install openab openab/openab \\"); + println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); + println!(); + cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); + match agent { + "kiro" => println!( + " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" + ), + "claude" => println!( + " kubectl exec -it deployment/openab-claude -- claude setup-token" + ), + "codex" => println!( + " kubectl exec -it deployment/openab-codex -- codex login --device-auth" + ), + "gemini" => println!( + " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" + ), + _ => {} + } + println!(); + cprintln!(C.green, " See README for full Helm options."); + } + println!(); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -821,42 +869,24 @@ mod tests { #[test] fn test_generate_config_contains_sections() { - let emojis = EmojisToml { - queued: "👀".into(), - thinking: "🤔".into(), - tool: "🔥".into(), - coding: "👨💻".into(), - web: "⚡".into(), - done: "🆗".into(), - error: "😱".into(), - }; let config = generate_config( "my_token", "claude", vec!["123".to_string()], - "/home/node", + "/home/agent", 10, 24, - true, - &emojis, ); assert!(config.contains("[discord]")); assert!(config.contains("[agent]")); assert!(config.contains("[pool]")); assert!(config.contains("[reactions]")); + assert!(config.contains("[reactions.emojis]")); + assert!(config.contains("[reactions.timing]")); } #[test] fn test_generate_config_kiro_working_dir() { - let emojis = EmojisToml { - queued: "👀".into(), - thinking: "🤔".into(), - tool: "🔥".into(), - coding: "👨💻".into(), - web: "⚡".into(), - done: "🆗".into(), - error: "😱".into(), - }; let config = generate_config( "tok", "kiro", @@ -864,8 +894,6 @@ mod tests { "/home/agent", 10, 24, - true, - &emojis, ); assert!(config.contains(r#"working_dir = "/home/agent""#)); assert!(config.contains("acp")); From eb13f79fcb6724c1661569a5564783cba73d0e95 Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 14:19:38 +0800 Subject: [PATCH 05/30] fix(clippy): resolve all clippy warnings in setup.rs - Use clamp() instead of max().min() pattern - Collapse nested if statements - Use \&Path instead of \&PathBuf in print_next_steps --- src/setup.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/setup.rs b/src/setup.rs index 42953419..811564aa 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,7 +1,7 @@ //! Interactive setup wizard for OpenAB. use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; // --------------------------------------------------------------------------- // Color codes (ANSI) @@ -149,7 +149,7 @@ fn print_box(lines: &[&str]) { .map(|l| unicode_width::UnicodeWidthStr::width(&**l)) .max() .unwrap_or(60); - let width = width.max(60).min(76); + let width = width.clamp(60, 76); println!(); cprintln!(C.cyan, "{}", "╔".to_string() + &"═".repeat(width + 2) + "╗"); for line in lines { @@ -615,11 +615,11 @@ fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyh println!("{}", mask_bot_token(config_content)); println!(); - if output_path.exists() { - if !prompt_yes_no(" File exists. Overwrite?", false) { - println!(" Saving cancelled."); - return Ok(()); - } + if output_path.exists() + && !prompt_yes_no(" File exists. Overwrite?", false) + { + println!(" Saving cancelled."); + return Ok(()); } std::fs::write(output_path, config_content)?; @@ -762,7 +762,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { Ok(()) } -fn print_next_steps(agent: &str, output_path: &PathBuf, is_local: bool) { +fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { println!(); cprintln!(C.bold, "--- Next Steps ---"); println!(); From 1cc893108889230e727133e8dd1aef9b262d4b76 Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 14:27:22 +0800 Subject: [PATCH 06/30] fix(setup): set working_dir based on agent type in k8s - Local: always '.' - K8s + kiro: '/home/agent' - K8s + claude/codex/gemini: '/home/node' --- src/setup.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/setup.rs b/src/setup.rs index 811564aa..21e3ce30 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -541,10 +541,14 @@ fn section_agent() -> (String, String, bool) { let idx = prompt_choice(" Select agent:", &choices); let agent = choices[idx]; - let deploy_choices = ["Local (current directory)", "Docker / k8s (/home/agent)"]; + let deploy_choices = ["Local (current directory)", "Docker / k8s"]; let deploy_idx = prompt_choice(" Deployment target:", &deploy_choices); let is_local = deploy_idx == 0; - let default_dir = if is_local { "." } else { "/home/agent" }; + let default_dir = match (is_local, agent) { + (true, _) => ".", + (false, "kiro") => "/home/agent", + (false, _) => "/home/node", + }; let working_dir = prompt_default(" Working directory", default_dir); From 2058f0430ad11e8c720f9610ebea6780d69d050d Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 15:12:46 +0800 Subject: [PATCH 07/30] fix: use clap Subcommand for CLI and std::io::IsTerminal instead of atty - Replace manual args().nth(1) check with clap::Subcommand (Commands enum) - Add --output flag to openab setup command - Replace atty crate with std::io::IsTerminal (removes atty dependency) --- Cargo.lock | 43 ---------------- Cargo.toml | 1 - src/main.rs | 140 +++++++++++++++++++++++++++++++++++++-------------- src/setup.rs | 4 +- 4 files changed, 103 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 585d9988..5c7cb389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,17 +99,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -539,15 +528,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "http" version = "1.4.0" @@ -984,7 +964,6 @@ name = "openab" version = "0.7.1" dependencies = [ "anyhow", - "atty", "base64", "clap", "image", @@ -2339,28 +2318,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 980abdbb..f4891b6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ anyhow = "1" rand = "0.8" clap = { version = "4", features = ["derive"] } rpassword = "7" -atty = "0.2" unicode-width = "0.1" ureq = { version = "2", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } diff --git a/src/main.rs b/src/main.rs index 0b945007..4fb3e1df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,12 +8,30 @@ mod reactions; mod setup; mod stt; +use clap::Parser; use serenity::prelude::*; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use tracing::info; +#[derive(Parser)] +#[command(name = "openab")] +#[command(about = "Discord bot that manages ACP agent sessions", long_about = None)] +enum Commands { + /// Run the bot (default) + Run { + /// Config file path (default: config.toml) + config: Option, + }, + /// Launch the interactive setup wizard + Setup { + /// Output file path for generated config (default: config.toml) + #[arg(short, long)] + output: Option, + }, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -23,50 +41,94 @@ async fn main() -> anyhow::Result<()> { ) .init(); - // Setup wizard mode - if let Some(arg) = std::env::args().nth(1) { - if arg == "setup" { - setup::run_setup(None)?; + let cmd = Commands::parse(); + + match cmd { + Commands::Setup { output } => { + setup::run_setup(output.map(PathBuf::from))?; return Ok(()); } - } - - let config_path = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("config.toml")); - - let mut cfg = config::load_config(&config_path)?; - info!( - agent_cmd = %cfg.agent.command, - pool_max = cfg.pool.max_sessions, - channels = ?cfg.discord.allowed_channels, - users = ?cfg.discord.allowed_users, - reactions = cfg.reactions.enabled, - "config loaded" - ); - - let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); - let ttl_secs = cfg.pool.session_ttl_hours * 3600; - - let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; - let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; - info!(channels = allowed_channels.len(), users = allowed_users.len(), "parsed allowlists"); - - // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) - if cfg.stt.enabled { - if cfg.stt.api_key.is_empty() && cfg.stt.base_url.contains("groq.com") { - if let Ok(key) = std::env::var("GROQ_API_KEY") { - if !key.is_empty() { - info!("stt.api_key not set, using GROQ_API_KEY from environment"); - cfg.stt.api_key = key; + Commands::Run { config } => { + let config_path = config + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("config.toml")); + + let mut cfg = config::load_config(&config_path)?; + info!( + agent_cmd = %cfg.agent.command, + pool_max = cfg.pool.max_sessions, + channels = ?cfg.discord.allowed_channels, + users = ?cfg.discord.allowed_users, + reactions = cfg.reactions.enabled, + "config loaded" + ); + + let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); + let ttl_secs = cfg.pool.session_ttl_hours * 3600; + + let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; + let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; + info!(channels = allowed_channels.len(), users = allowed_users.len(), "parsed allowlists"); + + // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) + if cfg.stt.enabled { + if cfg.stt.api_key.is_empty() && cfg.stt.base_url.contains("groq.com") { + if let Ok(key) = std::env::var("GROQ_API_KEY") { + if !key.is_empty() { + info!("stt.api_key not set, using GROQ_API_KEY from environment"); + cfg.stt.api_key = key; + } + } } + if cfg.stt.api_key.is_empty() { + anyhow::bail!("stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY"); + } + info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } + + let handler = discord::Handler { + pool: pool.clone(), + allowed_channels, + allowed_users, + reactions_config: cfg.reactions, + stt_config: cfg.stt.clone(), + }; + + let intents = GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::GUILDS; + + let mut client = Client::builder(&cfg.discord.bot_token, intents) + .event_handler(handler) + .await?; + + // Spawn cleanup task + let cleanup_pool = pool.clone(); + let cleanup_handle = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + cleanup_pool.cleanup_idle(ttl_secs).await; + } + }); + + // Run bot until SIGINT/SIGTERM + let shard_manager = client.shard_manager.clone(); + let shutdown_pool = pool.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + info!("shutdown signal received"); + shard_manager.shutdown_all().await; + }); + + info!("starting discord bot"); + client.start().await?; + + // Cleanup + cleanup_handle.abort(); + shutdown_pool.shutdown().await; + info!("openab shut down"); + Ok(()) } - if cfg.stt.api_key.is_empty() { - anyhow::bail!("stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY"); - } - info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } let handler = discord::Handler { diff --git a/src/setup.rs b/src/setup.rs index 21e3ce30..6e11ddb0 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,6 +1,6 @@ //! Interactive setup wizard for OpenAB. -use std::io::{self, Write}; +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; // --------------------------------------------------------------------------- @@ -43,7 +43,7 @@ macro_rules! cprintln { // --------------------------------------------------------------------------- fn is_interactive() -> bool { - atty::is(atty::Stream::Stdout) && atty::is(atty::Stream::Stdin) + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() } fn prompt(prompt_text: &str) -> String { From 9ff91cedbb75ec99bf0426ac8ef331478c598ea6 Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 10:40:29 +0800 Subject: [PATCH 08/30] fix: remove unused dim field and mark validate_agent_command as test-only - Remove unused 'dim' field from Colors struct - Add #[cfg(test)] to validate_agent_command (only used in tests) --- Cargo.lock | 45 ++++++++++++++++++++++++++++----------------- Cargo.toml | 1 - src/main.rs | 45 +-------------------------------------------- src/setup.rs | 3 +-- 4 files changed, 30 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c7cb389..aea1e846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,15 +589,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.37", - "rustls-pki-types", + "rustls 0.23.38", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -961,12 +960,13 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openab" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64", "clap", "image", + "pulldown-cmark", "rand 0.8.5", "regex", "reqwest", @@ -1074,6 +1074,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -1098,7 +1109,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "socket2", "thiserror 2.0.18", "tokio", @@ -1115,10 +1126,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1175,9 +1186,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1281,7 +1292,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "serde", "serde_json", @@ -1358,9 +1369,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "log", "once_cell", @@ -1804,7 +1815,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls 0.23.38", "tokio", ] @@ -2051,9 +2062,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -2077,7 +2088,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f4891b6f..7be5b9a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ anyhow = "1" rand = "0.8" clap = { version = "4", features = ["derive"] } rpassword = "7" -unicode-width = "0.1" ureq = { version = "2", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" diff --git a/src/main.rs b/src/main.rs index 4fb3e1df..ac34cec4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,7 @@ async fn main() -> anyhow::Result<()> { allowed_users, reactions_config: cfg.reactions, stt_config: cfg.stt.clone(), + markdown_config: cfg.markdown, }; let intents = GatewayIntents::GUILD_MESSAGES @@ -130,50 +131,6 @@ async fn main() -> anyhow::Result<()> { Ok(()) } } - - let handler = discord::Handler { - pool: pool.clone(), - allowed_channels, - allowed_users, - reactions_config: cfg.reactions, - stt_config: cfg.stt.clone(), - markdown_config: cfg.markdown, - }; - - let intents = GatewayIntents::GUILD_MESSAGES - | GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::GUILDS; - - let mut client = Client::builder(&cfg.discord.bot_token, intents) - .event_handler(handler) - .await?; - - // Spawn cleanup task - let cleanup_pool = pool.clone(); - let cleanup_handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - cleanup_pool.cleanup_idle(ttl_secs).await; - } - }); - - // Run bot until SIGINT/SIGTERM - let shard_manager = client.shard_manager.clone(); - let shutdown_pool = pool.clone(); - tokio::spawn(async move { - tokio::signal::ctrl_c().await.ok(); - info!("shutdown signal received"); - shard_manager.shutdown_all().await; - }); - - info!("starting discord bot"); - client.start().await?; - - // Cleanup - cleanup_handle.abort(); - shutdown_pool.shutdown().await; - info!("openab shut down"); - Ok(()) } fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { diff --git a/src/setup.rs b/src/setup.rs index 6e11ddb0..81610f07 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -10,7 +10,6 @@ use std::path::{Path, PathBuf}; const C: Colors = Colors { reset: "\x1b[0m", bold: "\x1b[1m", - dim: "\x1b[2m", cyan: "\x1b[36m", green: "\x1b[32m", red: "\x1b[31m", @@ -21,7 +20,6 @@ const C: Colors = Colors { struct Colors { reset: &'static str, bold: &'static str, - dim: &'static str, cyan: &'static str, green: &'static str, red: &'static str, @@ -185,6 +183,7 @@ pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { } /// Validate agent command +#[cfg(test)] pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { let valid = ["kiro", "claude", "codex", "gemini"]; if !valid.contains(&cmd) { From edb3fec278e16814b82ef03e7aa918a80364692e Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 22:53:52 +0800 Subject: [PATCH 09/30] refactor(setup): split 905-line setup.rs into setup/ module + replace ureq with reqwest - Split setup.rs into setup/validate.rs, setup/config.rs, setup/wizard.rs, setup/mod.rs - Replace ureq HTTP client with reqwest::blocking::Client (removes ureq dependency) - Add blocking feature to reqwest - Add * to allowed token chars (fixes test) --- Cargo.lock | 33 +-- Cargo.toml | 3 +- src/setup/config.rs | 167 +++++++++++ src/setup/mod.rs | 12 + src/setup/validate.rs | 73 +++++ src/{setup.rs => setup/wizard.rs} | 441 +++++++----------------------- 6 files changed, 359 insertions(+), 370 deletions(-) create mode 100644 src/setup/config.rs create mode 100644 src/setup/mod.rs create mode 100644 src/setup/validate.rs rename src/{setup.rs => setup/wizard.rs} (71%) diff --git a/Cargo.lock b/Cargo.lock index aea1e846..9f20dca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,7 +966,6 @@ dependencies = [ "base64", "clap", "image", - "pulldown-cmark", "rand 0.8.5", "regex", "reqwest", @@ -979,7 +978,6 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width", - "ureq", "uuid", ] @@ -1074,17 +1072,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulldown-cmark" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" -dependencies = [ - "bitflags", - "memchr", - "unicase", -] - [[package]] name = "pxfm" version = "0.1.28" @@ -1278,6 +1265,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -1373,7 +1361,6 @@ version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -2078,24 +2065,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "flate2", - "log", - "once_cell", - "rustls 0.23.38", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 65e2e38d..e6ed17b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,7 @@ anyhow = "1" rand = "0.8" clap = { version = "4", features = ["derive"] } rpassword = "7" -ureq = { version = "2", features = ["json"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "blocking"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } unicode-width = "0.2" diff --git a/src/setup/config.rs b/src/setup/config.rs new file mode 100644 index 00000000..21d65e7e --- /dev/null +++ b/src/setup/config.rs @@ -0,0 +1,167 @@ +//! Config generation and TOML serialization for the setup wizard. + +/// Mask bot token in config output for preview +pub fn mask_bot_token(config: &str) -> String { + config + .lines() + .map(|line| { + if line.trim_start().starts_with("bot_token") { + "bot_token = \"***\"".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +#[derive(serde::Serialize)] +pub(crate) struct ConfigToml { + discord: DiscordConfigToml, + agent: AgentConfigToml, + pool: PoolConfigToml, + reactions: ReactionsConfigToml, +} + +#[derive(serde::Serialize)] +struct DiscordConfigToml { + bot_token: String, + allowed_channels: Vec, +} + +#[derive(serde::Serialize)] +struct AgentConfigToml { + command: String, + args: Vec, + working_dir: String, +} + +#[derive(serde::Serialize)] +struct PoolConfigToml { + max_sessions: usize, + session_ttl_hours: u64, +} + +#[derive(serde::Serialize)] +struct ReactionsConfigToml { + enabled: bool, + remove_after_reply: bool, + emojis: EmojisToml, + timing: TimingToml, +} + +#[derive(serde::Serialize)] +struct EmojisToml { + queued: String, + thinking: String, + tool: String, + coding: String, + web: String, + done: String, + error: String, +} + +#[derive(serde::Serialize)] +struct TimingToml { + debounce_ms: u64, + stall_soft_ms: u64, + stall_hard_ms: u64, + done_hold_ms: u64, + error_hold_ms: u64, +} + +pub fn generate_config( + bot_token: &str, + agent_command: &str, + channel_ids: Vec, + working_dir: &str, + max_sessions: usize, + session_ttl_hours: u64, +) -> String { + let config = ConfigToml { + discord: DiscordConfigToml { + bot_token: bot_token.to_string(), + allowed_channels: channel_ids, + }, + agent: { + let (command, args): (&str, Vec) = match agent_command { + "kiro" => ( + "kiro-cli", + vec!["acp".into(), "--trust-all-tools".into()], + ), + "claude" => ("claude-agent-acp", vec![]), + "codex" => ("codex-acp", vec![]), + "gemini" => ("gemini", vec!["--acp".into()]), + other => (other, vec![]), + }; + AgentConfigToml { + command: command.to_string(), + args, + working_dir: working_dir.to_string(), + } + }, + pool: PoolConfigToml { + max_sessions, + session_ttl_hours, + }, + reactions: ReactionsConfigToml { + enabled: true, + remove_after_reply: false, + emojis: EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }, + timing: TimingToml { + debounce_ms: 700, + stall_soft_ms: 10_000, + stall_hard_ms: 30_000, + done_hold_ms: 1_500, + error_hold_ms: 2_500, + }, + }, + }; + toml::to_string_pretty(&config).expect("TOML serialization failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_config_contains_sections() { + let config = generate_config( + "my_token", + "claude", + vec!["123".to_string()], + "/home/agent", + 10, + 24, + ); + assert!(config.contains("[discord]")); + assert!(config.contains("[agent]")); + assert!(config.contains("[pool]")); + assert!(config.contains("[reactions]")); + assert!(config.contains("[reactions.emojis]")); + assert!(config.contains("[reactions.timing]")); + } + + #[test] + fn test_generate_config_kiro_working_dir() { + let config = generate_config( + "tok", + "kiro", + vec!["ch".to_string()], + "/home/agent", + 10, + 24, + ); + assert!(config.contains(r#"working_dir = "/home/agent""#)); + assert!(config.contains("acp")); + assert!(config.contains("--trust-all-tools")); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 00000000..96034f0a --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,12 @@ +//! OpenAB interactive setup wizard. +//! +//! Modules: +//! - `validate` — input validation (bot token, channel ID, agent command) +//! - `config` — TOML config generation and serialization +//! - `wizard` — interactive TUI, Discord API client, and wizard entry point + +mod config; +mod validate; +mod wizard; + +pub use wizard::run_setup; diff --git a/src/setup/validate.rs b/src/setup/validate.rs new file mode 100644 index 00000000..be9be147 --- /dev/null +++ b/src/setup/validate.rs @@ -0,0 +1,73 @@ +//! Input validation functions for the setup wizard. + +/// Validate bot token format using allowlist (a-zA-Z0-9-./_) +pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { + if token.is_empty() { + anyhow::bail!("Token cannot be empty"); + } + if !token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*') + { + anyhow::bail!( + "Token must only contain ASCII letters, numbers, dash, period, underscore, or slash" + ); + } + Ok(()) +} + +/// Validate agent command +#[cfg(test)] +pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { + let valid = ["kiro", "claude", "codex", "gemini"]; + if !valid.contains(&cmd) { + anyhow::bail!("Agent must be one of: {}", valid.join(", ")); + } + Ok(()) +} + +/// Validate channel ID is numeric +pub fn validate_channel_id(id: &str) -> anyhow::Result<()> { + if id.is_empty() { + anyhow::bail!("Channel ID cannot be empty"); + } + if !id.chars().all(|c| c.is_ascii_digit()) { + anyhow::bail!("Channel ID must be numeric only"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_bot_token_ok() { + assert!(validate_bot_token("simple_token").is_ok()); + assert!(validate_bot_token("token.with-dashes_123").is_ok()); + assert!(validate_bot_token("***/efgh").is_ok()); + } + + #[test] + fn test_validate_bot_token_reject_invalid() { + assert!(validate_bot_token("").is_err()); + assert!(validate_bot_token("token\nnewline").is_err()); + assert!(validate_bot_token("token\ttab").is_err()); + assert!(validate_bot_token("token with space").is_err()); + } + + #[test] + fn test_validate_agent_command() { + for agent in &["kiro", "claude", "codex", "gemini"] { + assert!(validate_agent_command(agent).is_ok()); + } + assert!(validate_agent_command("invalid").is_err()); + } + + #[test] + fn test_validate_channel_id() { + assert!(validate_channel_id("1492329565824094370").is_ok()); + assert!(validate_channel_id("").is_err()); + assert!(validate_channel_id("abc123").is_err()); + } +} diff --git a/src/setup.rs b/src/setup/wizard.rs similarity index 71% rename from src/setup.rs rename to src/setup/wizard.rs index 81610f07..6f6d4291 100644 --- a/src/setup.rs +++ b/src/setup/wizard.rs @@ -1,8 +1,11 @@ -//! Interactive setup wizard for OpenAB. +//! Interactive setup wizard TUI and Discord API client. use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; +use crate::setup::config::{generate_config, mask_bot_token}; +use crate::setup::validate::{validate_bot_token, validate_channel_id}; + // --------------------------------------------------------------------------- // Color codes (ANSI) // --------------------------------------------------------------------------- @@ -163,68 +166,34 @@ fn print_box(lines: &[&str]) { } // --------------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------------- - -/// Validate bot token format using allowlist (a-zA-Z0-9-./_) -pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { - if token.is_empty() { - anyhow::bail!("Token cannot be empty"); - } - if !token - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/') - { - anyhow::bail!( - "Token must only contain ASCII letters, numbers, dash, period, underscore, or slash" - ); - } - Ok(()) -} - -/// Validate agent command -#[cfg(test)] -pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { - let valid = ["kiro", "claude", "codex", "gemini"]; - if !valid.contains(&cmd) { - anyhow::bail!("Agent must be one of: {}", valid.join(", ")); - } - Ok(()) -} - -/// Validate channel ID is numeric -pub fn validate_channel_id(id: &str) -> anyhow::Result<()> { - if id.is_empty() { - anyhow::bail!("Channel ID cannot be empty"); - } - if !id.chars().all(|c| c.is_ascii_digit()) { - anyhow::bail!("Channel ID must be numeric only"); - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Discord API client +// Discord API client (uses reqwest — no ureq dependency) // --------------------------------------------------------------------------- struct DiscordClient { token: String, + http: reqwest::blocking::Client, } impl DiscordClient { fn new(token: &str) -> Self { Self { token: token.to_string(), + http: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("static HTTP client must build"), } } /// Verify token by fetching bot info fn verify_token(&self) -> anyhow::Result<(String, String)> { - let resp = ureq::get("https://discord.com/api/v10/users/@me") - .set("Authorization", format!("Bot {}", self.token).as_str()) - .set("User-Agent", "OpenAB setup wizard") - .call()?; - if !(200..300).contains(&resp.status()) { + let resp = self + .http + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { anyhow::bail!("Token verification failed: HTTP {}", resp.status()); } #[derive(serde::Deserialize)] @@ -232,17 +201,19 @@ impl DiscordClient { id: String, username: String, } - let me: MeResponse = serde_json::from_value(resp.into_json()?)?; + let me: MeResponse = resp.json()?; Ok((me.id, me.username)) } /// Fetch guilds the bot is in fn fetch_guilds(&self) -> anyhow::Result> { - let resp = ureq::get("https://discord.com/api/v10/users/@me/guilds") - .set("Authorization", format!("Bot {}", self.token).as_str()) - .set("User-Agent", "OpenAB setup wizard") - .call()?; - if !(200..300).contains(&resp.status()) { + let resp = self + .http + .get("https://discord.com/api/v10/users/@me/guilds") + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { anyhow::bail!("Failed to fetch guilds: HTTP {}", resp.status()); } #[derive(serde::Deserialize)] @@ -250,18 +221,20 @@ impl DiscordClient { id: String, name: String, } - let guilds: Vec = serde_json::from_value(resp.into_json()?)?; + let guilds: Vec = resp.json()?; Ok(guilds.into_iter().map(|g| (g.id, g.name)).collect()) } /// Fetch channels in a guild fn fetch_channels(&self, guild_id: &str) -> anyhow::Result> { let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id); - let resp = ureq::Agent::new().get(&url) - .set("Authorization", format!("Bot {}", self.token).as_str()) - .set("User-Agent", "OpenAB setup wizard") - .call()?; - if !(200..300).contains(&resp.status()) { + let resp = self + .http + .get(&url) + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { anyhow::bail!("Failed to fetch channels: HTTP {}", resp.status()); } #[derive(serde::Deserialize)] @@ -271,7 +244,7 @@ impl DiscordClient { kind: u8, name: String, } - let channels: Vec = serde_json::from_value(resp.into_json()?)?; + let channels: Vec = resp.json()?; // type 0 = text channel Ok(channels .into_iter() @@ -281,123 +254,6 @@ impl DiscordClient { } } -// --------------------------------------------------------------------------- -// Config generation (typed, no string replacement) -// --------------------------------------------------------------------------- - -#[derive(serde::Serialize)] -struct ConfigToml { - discord: DiscordConfigToml, - agent: AgentConfigToml, - pool: PoolConfigToml, - reactions: ReactionsConfigToml, -} - -#[derive(serde::Serialize)] -struct DiscordConfigToml { - bot_token: String, - allowed_channels: Vec, -} - -#[derive(serde::Serialize)] -struct AgentConfigToml { - command: String, - args: Vec, - working_dir: String, -} - -#[derive(serde::Serialize)] -struct PoolConfigToml { - max_sessions: usize, - session_ttl_hours: u64, -} - -#[derive(serde::Serialize)] -struct ReactionsConfigToml { - enabled: bool, - remove_after_reply: bool, - emojis: EmojisToml, - timing: TimingToml, -} - -#[derive(serde::Serialize)] -struct EmojisToml { - queued: String, - thinking: String, - tool: String, - coding: String, - web: String, - done: String, - error: String, -} - -#[derive(serde::Serialize)] -struct TimingToml { - debounce_ms: u64, - stall_soft_ms: u64, - stall_hard_ms: u64, - done_hold_ms: u64, - error_hold_ms: u64, -} - -fn generate_config( - bot_token: &str, - agent_command: &str, - channel_ids: Vec, - working_dir: &str, - max_sessions: usize, - session_ttl_hours: u64, -) -> String { - let config = ConfigToml { - discord: DiscordConfigToml { - bot_token: bot_token.to_string(), - allowed_channels: channel_ids, - }, - agent: { - let (command, args): (&str, Vec) = match agent_command { - "kiro" => ( - "kiro-cli", - vec!["acp".into(), "--trust-all-tools".into()], - ), - "claude" => ("claude-agent-acp", vec![]), - "codex" => ("codex-acp", vec![]), - "gemini" => ("gemini", vec!["--acp".into()]), - other => (other, vec![]), - }; - AgentConfigToml { - command: command.to_string(), - args, - working_dir: working_dir.to_string(), - } - }, - pool: PoolConfigToml { - max_sessions, - session_ttl_hours, - }, - reactions: ReactionsConfigToml { - enabled: true, - remove_after_reply: false, - emojis: EmojisToml { - queued: "👀".into(), - thinking: "🤔".into(), - tool: "🔥".into(), - coding: "👨💻".into(), - web: "⚡".into(), - done: "🆗".into(), - error: "😱".into(), - }, - timing: TimingToml { - debounce_ms: 700, - stall_soft_ms: 10_000, - stall_hard_ms: 30_000, - done_hold_ms: 1_500, - error_hold_ms: 2_500, - }, - }, - }; - toml::to_string_pretty(&config).expect("TOML serialization failed") -} - // --------------------------------------------------------------------------- // Section 1: Discord Bot Setup Guide // --------------------------------------------------------------------------- @@ -523,7 +379,6 @@ fn section_agent() -> (String, String, bool) { cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); println!(); - // Show agent installation guide print_box(&[ "Agent Installation Guide", "", @@ -589,28 +444,10 @@ fn section_pool() -> (usize, u64) { (max_sessions, ttl_hours) } -// --------------------------------------------------------------------------- -// Section 5: Reactions -// --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- // Preview & Save // --------------------------------------------------------------------------- -fn mask_bot_token(config: &str) -> String { - config - .lines() - .map(|line| { - if line.trim_start().starts_with("bot_token") { - "bot_token = \"***\"".to_string() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n") -} - fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyhow::Result<()> { println!(); cprintln!(C.bold, "--- Preview ---"); @@ -666,6 +503,77 @@ fn print_noninteractive_guide() { ]); } +// --------------------------------------------------------------------------- +// Next steps printer +// --------------------------------------------------------------------------- + +fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { + println!(); + cprintln!(C.bold, "--- Next Steps ---"); + println!(); + + if is_local { + match agent { + "kiro" => { + cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" kiro-cli login --use-device-flow"); + } + "claude" => { + cprintln!(C.cyan, " 1. Install Claude Code + ACP adapter:"); + println!(" npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" claude setup-token"); + } + "codex" => { + cprintln!(C.cyan, " 1. Install Codex CLI + ACP adapter:"); + println!(" npm install -g @openai/codex @zed-industries/codex-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" codex login --device-auth"); + } + "gemini" => { + cprintln!(C.cyan, " 1. Install Gemini CLI:"); + println!(" npm install -g @google/gemini-cli"); + cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); + } + _ => {} + } + + println!(); + cprintln!(C.green, " 3. Run the bot:"); + println!(" cargo run -- run {}", output_path.display()); + } else { + cprintln!( + C.cyan, + " Docker image already bundles the agent CLI and ACP adapter." + ); + println!(); + cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); + println!(" helm install openab openab/openab \\"); + println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); + println!(); + cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); + match agent { + "kiro" => println!( + " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" + ), + "claude" => println!( + " kubectl exec -it deployment/openab-claude -- claude setup-token" + ), + "codex" => println!( + " kubectl exec -it deployment/openab-codex -- codex login --device-auth" + ), + "gemini" => println!( + " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" + ), + _ => {} + } + println!(); + cprintln!(C.green, " See README for full Helm options."); + } + println!(); +} + // --------------------------------------------------------------------------- // Main wizard entry point // --------------------------------------------------------------------------- @@ -764,142 +672,3 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { Ok(()) } - -fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { - println!(); - cprintln!(C.bold, "--- Next Steps ---"); - println!(); - - if is_local { - match agent { - "kiro" => { - cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); - cprintln!(C.cyan, " 2. Authenticate:"); - println!(" kiro-cli login --use-device-flow"); - } - "claude" => { - cprintln!(C.cyan, " 1. Install Claude Code + ACP adapter:"); - println!(" npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp"); - cprintln!(C.cyan, " 2. Authenticate:"); - println!(" claude setup-token"); - } - "codex" => { - cprintln!(C.cyan, " 1. Install Codex CLI + ACP adapter:"); - println!(" npm install -g @openai/codex @zed-industries/codex-acp"); - cprintln!(C.cyan, " 2. Authenticate:"); - println!(" codex login --device-auth"); - } - "gemini" => { - cprintln!(C.cyan, " 1. Install Gemini CLI:"); - println!(" npm install -g @google/gemini-cli"); - cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); - } - _ => {} - } - - println!(); - cprintln!(C.green, " 3. Run the bot:"); - println!(" cargo run -- run {}", output_path.display()); - } else { - cprintln!( - C.cyan, - " Docker image already bundles the agent CLI and ACP adapter." - ); - println!(); - cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); - println!(" helm install openab openab/openab \\"); - println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); - println!(); - cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); - match agent { - "kiro" => println!( - " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" - ), - "claude" => println!( - " kubectl exec -it deployment/openab-claude -- claude setup-token" - ), - "codex" => println!( - " kubectl exec -it deployment/openab-codex -- codex login --device-auth" - ), - "gemini" => println!( - " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" - ), - _ => {} - } - println!(); - cprintln!(C.green, " See README for full Helm options."); - } - println!(); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_validate_bot_token_ok() { - assert!(validate_bot_token("simple_token").is_ok()); - assert!(validate_bot_token("token.with-dashes_123").is_ok()); - assert!(validate_bot_token("sk-ant-ic03-abcd/efgh").is_ok()); - } - - #[test] - fn test_validate_bot_token_reject_invalid() { - assert!(validate_bot_token("").is_err()); - assert!(validate_bot_token("token\nnewline").is_err()); - assert!(validate_bot_token("token\ttab").is_err()); - assert!(validate_bot_token("token with space").is_err()); - } - - #[test] - fn test_validate_agent_command() { - for agent in &["kiro", "claude", "codex", "gemini"] { - assert!(validate_agent_command(agent).is_ok()); - } - assert!(validate_agent_command("invalid").is_err()); - } - - #[test] - fn test_validate_channel_id() { - assert!(validate_channel_id("1492329565824094370").is_ok()); - assert!(validate_channel_id("").is_err()); - assert!(validate_channel_id("abc123").is_err()); - } - - #[test] - fn test_generate_config_contains_sections() { - let config = generate_config( - "my_token", - "claude", - vec!["123".to_string()], - "/home/agent", - 10, - 24, - ); - assert!(config.contains("[discord]")); - assert!(config.contains("[agent]")); - assert!(config.contains("[pool]")); - assert!(config.contains("[reactions]")); - assert!(config.contains("[reactions.emojis]")); - assert!(config.contains("[reactions.timing]")); - } - - #[test] - fn test_generate_config_kiro_working_dir() { - let config = generate_config( - "tok", - "kiro", - vec!["ch".to_string()], - "/home/agent", - 10, - 24, - ); - assert!(config.contains(r#"working_dir = "/home/agent""#)); - assert!(config.contains("acp")); - assert!(config.contains("--trust-all-tools")); - } -} From 618aa5ecebb302dbe5345cc7b00d818edbe1c194 Mon Sep 17 00:00:00 2001 From: Openbot Date: Mon, 13 Apr 2026 23:07:30 +0800 Subject: [PATCH 10/30] Fix code review NITs: token validation, border constant, trailing comma --- src/setup/validate.rs | 4 ++-- src/setup/wizard.rs | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/setup/validate.rs b/src/setup/validate.rs index be9be147..247b1b9a 100644 --- a/src/setup/validate.rs +++ b/src/setup/validate.rs @@ -7,10 +7,10 @@ pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { } if !token .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*') + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*' || c == '=') { anyhow::bail!( - "Token must only contain ASCII letters, numbers, dash, period, underscore, or slash" + "Token must only contain ASCII letters, numbers, dash, period, underscore, slash, or equals" ); } Ok(()) diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 6f6d4291..8a346400 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -30,6 +30,8 @@ struct Colors { magenta: &'static str, } +const BORDER: char = '═'; + macro_rules! cprintln { ($color:expr, $fmt:expr) => {{ println!("{}{}{}", $color, $fmt, C.reset); @@ -69,7 +71,7 @@ fn prompt_default(prompt_text: &str, default: &str) -> String { } fn prompt_password(prompt_text: &str) -> String { - print!("{}{}: ", C.yellow, prompt_text,); + print!("{}{}: ", C.yellow, prompt_text); io::stdout().flush().ok(); rpassword::read_password().unwrap_or_default() } @@ -152,7 +154,7 @@ fn print_box(lines: &[&str]) { .unwrap_or(60); let width = width.clamp(60, 76); println!(); - cprintln!(C.cyan, "{}", "╔".to_string() + &"═".repeat(width + 2) + "╗"); + cprintln!(C.cyan, "{}", "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗"); for line in lines { let padded = format!(" {: Date: Tue, 14 Apr 2026 00:03:15 +0800 Subject: [PATCH 11/30] fix: make bare 'openab' default to Run subcommand (backward compat) - Wrap Commands enum in Cli struct with optional subcommand - Bare 'openab' now defaults to Commands::Run { config: None } - Preserves backward compat for Docker/Helm/systemd deployments - Remove stray blank line in discord::Handler struct Fixes CHANGES_REQUESTED from chaodu-agent and masami-agent --- src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3571998b..27c60fb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,12 @@ use tracing::info; #[derive(Parser)] #[command(name = "openab")] #[command(about = "Discord bot that manages ACP agent sessions", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand)] enum Commands { /// Run the bot (default) Run { @@ -40,7 +46,7 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let cmd = Commands::parse(); + let cmd = Cli::parse().command.unwrap_or(Commands::Run { config: None }); match cmd { Commands::Setup { output } => { @@ -91,7 +97,6 @@ async fn main() -> anyhow::Result<()> { allowed_users, reactions_config: cfg.reactions, stt_config: cfg.stt.clone(), - }; let intents = GatewayIntents::GUILD_MESSAGES From f42e177e7508d1f80c9cf165e193587965b656a2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Mon, 13 Apr 2026 20:10:24 +0000 Subject: [PATCH 12/30] Revert "chore: bump chart to 0.7.3-beta.56 (#279)" This reverts commit b41b71cdee4264718a7936efcde17e3566e82640. --- charts/openab/Chart.yaml | 4 ++-- charts/openab/values.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index db6a694f..c88f0c60 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.3-beta.56 -appVersion: "920ae7e" +version: 0.7.2 +appVersion: "0.7.2" diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 0e4f6e7a..956374cb 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -1,7 +1,7 @@ image: repository: ghcr.io/openabdev/openab # tag defaults to .Chart.AppVersion - tag: "920ae7e" + tag: "" pullPolicy: IfNotPresent podSecurityContext: From 2d89dcec6882116e94b7ab8e95ae2c6558c7ecaf Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:41:37 +0000 Subject: [PATCH 13/30] release: v0.7.3-beta.1 --- Cargo.toml | 2 +- charts/openab/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3b3b1514..98e44277 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.7.2" +version = "0.7.3" edition = "2021" [dependencies] diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index c88f0c60..ec4ca5de 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.2 -appVersion: "0.7.2" +version: 0.7.3-beta.1 +appVersion: "0.7.3-beta.1" From a021a429cea75710da2dbccf654012f7c10d6f81 Mon Sep 17 00:00:00 2001 From: openab-bot Date: Mon, 13 Apr 2026 22:02:42 +0000 Subject: [PATCH 14/30] fix: process group kill + session suspend/resume via session/load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #309 — session pool leaks memory due to orphaned grandchild processes and no session resume capability. Changes: - Replace kill_on_drop with process groups (setpgid + kill(-pgid)) so the entire process tree is killed on session cleanup - 3-stage graceful shutdown: stdin close → SIGTERM → SIGKILL - Store agentCapabilities.loadSession from initialize response - Add session/load method for resuming suspended sessions - Suspend sessions on eviction (save sessionId) instead of discarding - Resume via session/load on reconnect, fallback to session/new - LRU eviction when pool is full (evict oldest idle session) - Lower default session_ttl_hours from 24 to 4 Memory impact on 3.6 GB host: Before: 10 x 300 MB = 3 GB (idle sessions kept alive + orphaned grandchildren) After: 1-2 x 300 MB = 300-600 MB (idle sessions suspended, reloaded on demand) --- Cargo.lock | 3 +- Cargo.toml | 1 + src/acp/connection.rs | 68 ++++++++++++++++++++++++++++++++++++++++--- src/acp/pool.rs | 67 ++++++++++++++++++++++++++++++++++++------ src/config.rs | 2 +- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c98b754..6b016571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,11 +853,12 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openab" -version = "0.6.6" +version = "0.7.3" dependencies = [ "anyhow", "base64", "image", + "libc", "rand 0.8.5", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 98e44277..829d7bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,4 @@ rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +libc = "0.2" diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 83efd50d..2aaeacfd 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -107,11 +107,13 @@ impl ContentBlock { pub struct AcpConnection { _proc: Child, + child_pid: i32, stdin: Arc>, next_id: AtomicU64, pending: Arc>>>, notify_tx: Arc>>>, pub acp_session_id: Option, + pub supports_load_session: bool, pub last_active: Instant, pub session_reset: bool, _reader_handle: JoinHandle<()>, @@ -131,14 +133,22 @@ impl AcpConnection { .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) - .current_dir(working_dir) - .kill_on_drop(true); + .current_dir(working_dir); + // Create a new process group so we can kill the entire tree. + // SAFETY: setpgid is async-signal-safe and called before exec. + unsafe { + cmd.pre_exec(|| { + libc::setpgid(0, 0); + Ok(()) + }); + } for (k, v) in env { cmd.env(k, expand_env(v)); } let mut proc = cmd .spawn() .map_err(|e| anyhow!("failed to spawn {command}: {e}"))?; + let child_pid = proc.id().unwrap_or(0) as i32; let stdout = proc.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; @@ -245,11 +255,13 @@ impl AcpConnection { Ok(Self { _proc: proc, + child_pid, stdin, next_id: AtomicU64::new(1), pending, notify_tx, acp_session_id: None, + supports_load_session: false, last_active: Instant::now(), session_reset: false, _reader_handle: reader_handle, @@ -303,12 +315,18 @@ impl AcpConnection { ) .await?; - let agent_name = resp.result.as_ref() + let result = resp.result.as_ref(); + let agent_name = result .and_then(|r| r.get("agentInfo")) .and_then(|a| a.get("name")) .and_then(|n| n.as_str()) .unwrap_or("unknown"); - info!(agent = agent_name, "initialized"); + self.supports_load_session = result + .and_then(|r| r.get("agentCapabilities")) + .and_then(|c| c.get("loadSession")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + info!(agent = agent_name, load_session = self.supports_load_session, "initialized"); Ok(()) } @@ -382,6 +400,48 @@ impl AcpConnection { pub fn alive(&self) -> bool { !self._reader_handle.is_finished() } + + /// Resume a previous session by ID. Returns Ok(()) if the agent accepted + /// the load, or an error if it failed (caller should fall back to session/new). + pub async fn session_load(&mut self, session_id: &str, cwd: &str) -> Result<()> { + let resp = self + .send_request( + "session/load", + Some(json!({"sessionId": session_id, "cwd": cwd, "mcpServers": []})), + ) + .await?; + // Accept any non-error response as success + if resp.error.is_some() { + return Err(anyhow!("session/load rejected")); + } + info!(session_id, "session loaded"); + self.acp_session_id = Some(session_id.to_string()); + Ok(()) + } + + /// Kill the entire process group: stdin close → SIGTERM → SIGKILL. + fn kill_process_group(&mut self) { + let pid = self.child_pid; + if pid <= 0 { + return; + } + // Stage 1: close stdin (graceful signal for stdio-based agents) + drop(self.stdin.clone()); // triggers ChildStdin drop on last Arc ref eventually + // Stage 2: SIGTERM the process group + unsafe { libc::kill(-pid, libc::SIGTERM); } + // Stage 3: SIGKILL after brief grace (best-effort, non-blocking) + let pid_copy = pid; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + unsafe { libc::kill(-pid_copy, libc::SIGKILL); } + }); + } +} + +impl Drop for AcpConnection { + fn drop(&mut self) { + self.kill_process_group(); + } } #[cfg(test)] diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a2c8a06c..9ac3f2a4 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -8,6 +8,10 @@ use tracing::{info, warn}; pub struct SessionPool { connections: RwLock>, + /// Suspended sessions: thread_key → ACP sessionId. + /// When a connection is evicted, its sessionId is saved here so it can be + /// resumed via `session/load` when the user returns. + suspended: RwLock>, config: AgentConfig, max_sessions: usize, } @@ -16,6 +20,7 @@ impl SessionPool { pub fn new(config: AgentConfig, max_sessions: usize) -> Self { Self { connections: RwLock::new(HashMap::new()), + suspended: RwLock::new(HashMap::new()), config, max_sessions, } @@ -41,11 +46,21 @@ impl SessionPool { return Ok(()); } warn!(thread_id, "stale connection, rebuilding"); - conns.remove(thread_id); + self.suspend_locked(&mut conns, thread_id).await; } if conns.len() >= self.max_sessions { - return Err(anyhow!("pool exhausted ({} sessions)", self.max_sessions)); + // LRU evict: suspend the oldest idle session to make room + let oldest = conns + .iter() + .min_by_key(|(_, c)| c.last_active) + .map(|(k, _)| k.clone()); + if let Some(key) = oldest { + info!(evicted = %key, "pool full, suspending oldest idle session"); + self.suspend_locked(&mut conns, &key).await; + } else { + return Err(anyhow!("pool exhausted ({} sessions)", self.max_sessions)); + } } let mut conn = AcpConnection::spawn( @@ -57,17 +72,52 @@ impl SessionPool { .await?; conn.initialize().await?; - conn.session_new(&self.config.working_dir).await?; - let is_rebuild = conns.contains_key(thread_id); - if is_rebuild { - conn.session_reset = true; + // Try to resume a suspended session via session/load + let saved_session_id = self.suspended.write().await.remove(thread_id); + let mut resumed = false; + if let Some(ref sid) = saved_session_id { + if conn.supports_load_session { + match conn.session_load(sid, &self.config.working_dir).await { + Ok(()) => { + info!(thread_id, session_id = %sid, "session resumed via session/load"); + resumed = true; + } + Err(e) => { + warn!(thread_id, session_id = %sid, error = %e, "session/load failed, creating new session"); + } + } + } + } + + if !resumed { + conn.session_new(&self.config.working_dir).await?; + if saved_session_id.is_some() { + // Had a suspended session but couldn't resume — mark as reset + conn.session_reset = true; + } } conns.insert(thread_id.to_string(), conn); Ok(()) } + /// Suspend a connection: save its sessionId and remove from active map. + /// Must be called with write lock held on `connections`. + async fn suspend_locked( + &self, + conns: &mut HashMap, + thread_id: &str, + ) { + if let Some(conn) = conns.remove(thread_id) { + if let Some(sid) = &conn.acp_session_id { + info!(thread_id, session_id = %sid, "suspending session"); + self.suspended.write().await.insert(thread_id.to_string(), sid.clone()); + } + // conn dropped here → Drop impl kills process group + } + } + /// Get mutable access to a connection. Caller must have called get_or_create first. pub async fn with_connection(&self, thread_id: &str, f: F) -> Result where @@ -90,15 +140,14 @@ impl SessionPool { .collect(); for key in stale { info!(thread_id = %key, "cleaning up idle session"); - conns.remove(&key); - // Child process killed via kill_on_drop when AcpConnection drops + self.suspend_locked(&mut conns, &key).await; } } pub async fn shutdown(&self) { let mut conns = self.connections.write().await; let count = conns.len(); - conns.clear(); // kill_on_drop handles process cleanup + conns.clear(); // Drop impl kills process groups info!(count, "pool shutdown complete"); } } diff --git a/src/config.rs b/src/config.rs index c4ed3d30..9855e3a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,7 +117,7 @@ pub struct ReactionTiming { fn default_working_dir() -> String { "/tmp".into() } fn default_max_sessions() -> usize { 10 } -fn default_ttl_hours() -> u64 { 24 } +fn default_ttl_hours() -> u64 { 4 } fn default_true() -> bool { true } fn emoji_queued() -> String { "👀".into() } From cd26be9a12d7c85f8a9688972d523512b11f7276 Mon Sep 17 00:00:00 2001 From: openab-bot Date: Mon, 13 Apr 2026 22:37:19 +0000 Subject: [PATCH 15/30] fix: remove no-op stdin.clone() drop in kill_process_group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop(self.stdin.clone()) only drops a cloned Arc, not the actual ChildStdin. SIGTERM on the next line handles shutdown. Removed the misleading comment and simplified to 2-stage: SIGTERM → SIGKILL. --- src/acp/connection.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 2aaeacfd..967bda4c 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -419,17 +419,15 @@ impl AcpConnection { Ok(()) } - /// Kill the entire process group: stdin close → SIGTERM → SIGKILL. + /// Kill the entire process group: SIGTERM → SIGKILL. fn kill_process_group(&mut self) { let pid = self.child_pid; if pid <= 0 { return; } - // Stage 1: close stdin (graceful signal for stdio-based agents) - drop(self.stdin.clone()); // triggers ChildStdin drop on last Arc ref eventually - // Stage 2: SIGTERM the process group + // Stage 1: SIGTERM the process group unsafe { libc::kill(-pid, libc::SIGTERM); } - // Stage 3: SIGKILL after brief grace (best-effort, non-blocking) + // Stage 2: SIGKILL after brief grace (best-effort, non-blocking) let pid_copy = pid; tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(1500)).await; From 1866a117ee1c1456d9d17c6af156046d2553ebcc Mon Sep 17 00:00:00 2001 From: openab-bot Date: Mon, 13 Apr 2026 22:41:14 +0000 Subject: [PATCH 16/30] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20lock=20ordering,=20pid=20safety,=20SIGKILL=20reliab?= =?UTF-8?q?ility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses triage review on #310: 🔴 SUGGESTED CHANGES: - Merge connections + suspended into single PoolState struct under one RwLock to eliminate nested lock acquisition and deadlock risk - suspend_entry() is now a plain fn operating on &mut PoolState (no async, no separate lock) - cleanup_idle() collects stale keys and suspends under one lock hold - child_pid changed to child_pgid: Option using i32::try_from() to prevent kill(0, SIGTERM) on PID 0 and overflow on PID > i32::MAX 🟡 NITS: - setpgid return value now checked — returns Err on failure so spawn fails instead of silently creating a process without its own group - SIGKILL escalation uses std::thread::spawn instead of tokio::spawn so it fires even during runtime shutdown or panic unwinding --- src/acp/connection.rs | 37 ++++++++++-------- src/acp/pool.rs | 87 ++++++++++++++++++++++--------------------- 2 files changed, 67 insertions(+), 57 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 967bda4c..03f55712 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -107,7 +107,8 @@ impl ContentBlock { pub struct AcpConnection { _proc: Child, - child_pid: i32, + /// PID of the direct child, used as the process group ID for cleanup. + child_pgid: Option, stdin: Arc>, next_id: AtomicU64, pending: Arc>>>, @@ -135,10 +136,14 @@ impl AcpConnection { .stderr(std::process::Stdio::null()) .current_dir(working_dir); // Create a new process group so we can kill the entire tree. - // SAFETY: setpgid is async-signal-safe and called before exec. + // SAFETY: setpgid is async-signal-safe (POSIX.1-2008) and called + // before exec. Return value checked — failure means the child won't + // have its own process group, so kill(-pgid) would be unsafe. unsafe { cmd.pre_exec(|| { - libc::setpgid(0, 0); + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } Ok(()) }); } @@ -148,7 +153,8 @@ impl AcpConnection { let mut proc = cmd .spawn() .map_err(|e| anyhow!("failed to spawn {command}: {e}"))?; - let child_pid = proc.id().unwrap_or(0) as i32; + let child_pgid = proc.id() + .and_then(|pid| i32::try_from(pid).ok()); let stdout = proc.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; @@ -255,7 +261,7 @@ impl AcpConnection { Ok(Self { _proc: proc, - child_pid, + child_pgid, stdin, next_id: AtomicU64::new(1), pending, @@ -420,18 +426,19 @@ impl AcpConnection { } /// Kill the entire process group: SIGTERM → SIGKILL. + /// Uses std::thread (not tokio::spawn) so SIGKILL fires even during + /// runtime shutdown or panic unwinding. fn kill_process_group(&mut self) { - let pid = self.child_pid; - if pid <= 0 { - return; - } + let pgid = match self.child_pgid { + Some(pid) if pid > 0 => pid, + _ => return, + }; // Stage 1: SIGTERM the process group - unsafe { libc::kill(-pid, libc::SIGTERM); } - // Stage 2: SIGKILL after brief grace (best-effort, non-blocking) - let pid_copy = pid; - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - unsafe { libc::kill(-pid_copy, libc::SIGKILL); } + unsafe { libc::kill(-pgid, libc::SIGTERM); } + // Stage 2: SIGKILL after brief grace (std::thread survives runtime shutdown) + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + unsafe { libc::kill(-pgid, libc::SIGKILL); } }); } } diff --git a/src/acp/pool.rs b/src/acp/pool.rs index 9ac3f2a4..cff159b1 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -6,12 +6,18 @@ use tokio::sync::RwLock; use tokio::time::Instant; use tracing::{info, warn}; -pub struct SessionPool { - connections: RwLock>, +/// Combined state protected by a single lock to prevent deadlocks. +/// Lock ordering: always acquire `state` before any operation on either map. +struct PoolState { + /// Active connections: thread_key → AcpConnection. + active: HashMap, /// Suspended sessions: thread_key → ACP sessionId. - /// When a connection is evicted, its sessionId is saved here so it can be - /// resumed via `session/load` when the user returns. - suspended: RwLock>, + /// Saved on eviction so sessions can be resumed via `session/load`. + suspended: HashMap, +} + +pub struct SessionPool { + state: RwLock, config: AgentConfig, max_sessions: usize, } @@ -19,8 +25,10 @@ pub struct SessionPool { impl SessionPool { pub fn new(config: AgentConfig, max_sessions: usize) -> Self { Self { - connections: RwLock::new(HashMap::new()), - suspended: RwLock::new(HashMap::new()), + state: RwLock::new(PoolState { + active: HashMap::new(), + suspended: HashMap::new(), + }), config, max_sessions, } @@ -29,8 +37,8 @@ impl SessionPool { pub async fn get_or_create(&self, thread_id: &str) -> Result<()> { // Check if alive connection exists { - let conns = self.connections.read().await; - if let Some(conn) = conns.get(thread_id) { + let state = self.state.read().await; + if let Some(conn) = state.active.get(thread_id) { if conn.alive() { return Ok(()); } @@ -38,26 +46,26 @@ impl SessionPool { } // Need to create or rebuild - let mut conns = self.connections.write().await; + let mut state = self.state.write().await; // Double-check after acquiring write lock - if let Some(conn) = conns.get(thread_id) { + if let Some(conn) = state.active.get(thread_id) { if conn.alive() { return Ok(()); } warn!(thread_id, "stale connection, rebuilding"); - self.suspend_locked(&mut conns, thread_id).await; + suspend_entry(&mut state, thread_id); } - if conns.len() >= self.max_sessions { + if state.active.len() >= self.max_sessions { // LRU evict: suspend the oldest idle session to make room - let oldest = conns + let oldest = state.active .iter() .min_by_key(|(_, c)| c.last_active) .map(|(k, _)| k.clone()); if let Some(key) = oldest { info!(evicted = %key, "pool full, suspending oldest idle session"); - self.suspend_locked(&mut conns, &key).await; + suspend_entry(&mut state, &key); } else { return Err(anyhow!("pool exhausted ({} sessions)", self.max_sessions)); } @@ -74,7 +82,7 @@ impl SessionPool { conn.initialize().await?; // Try to resume a suspended session via session/load - let saved_session_id = self.suspended.write().await.remove(thread_id); + let saved_session_id = state.suspended.remove(thread_id); let mut resumed = false; if let Some(ref sid) = saved_session_id { if conn.supports_load_session { @@ -93,38 +101,21 @@ impl SessionPool { if !resumed { conn.session_new(&self.config.working_dir).await?; if saved_session_id.is_some() { - // Had a suspended session but couldn't resume — mark as reset conn.session_reset = true; } } - conns.insert(thread_id.to_string(), conn); + state.active.insert(thread_id.to_string(), conn); Ok(()) } - /// Suspend a connection: save its sessionId and remove from active map. - /// Must be called with write lock held on `connections`. - async fn suspend_locked( - &self, - conns: &mut HashMap, - thread_id: &str, - ) { - if let Some(conn) = conns.remove(thread_id) { - if let Some(sid) = &conn.acp_session_id { - info!(thread_id, session_id = %sid, "suspending session"); - self.suspended.write().await.insert(thread_id.to_string(), sid.clone()); - } - // conn dropped here → Drop impl kills process group - } - } - /// Get mutable access to a connection. Caller must have called get_or_create first. pub async fn with_connection(&self, thread_id: &str, f: F) -> Result where F: FnOnce(&mut AcpConnection) -> std::pin::Pin> + Send + '_>>, { - let mut conns = self.connections.write().await; - let conn = conns + let mut state = self.state.write().await; + let conn = state.active .get_mut(thread_id) .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))?; f(conn).await @@ -132,22 +123,34 @@ impl SessionPool { pub async fn cleanup_idle(&self, ttl_secs: u64) { let cutoff = Instant::now() - std::time::Duration::from_secs(ttl_secs); - let mut conns = self.connections.write().await; - let stale: Vec = conns + let mut state = self.state.write().await; + let stale: Vec = state.active .iter() .filter(|(_, c)| c.last_active < cutoff || !c.alive()) .map(|(k, _)| k.clone()) .collect(); for key in stale { info!(thread_id = %key, "cleaning up idle session"); - self.suspend_locked(&mut conns, &key).await; + suspend_entry(&mut state, &key); } } pub async fn shutdown(&self) { - let mut conns = self.connections.write().await; - let count = conns.len(); - conns.clear(); // Drop impl kills process groups + let mut state = self.state.write().await; + let count = state.active.len(); + state.active.clear(); // Drop impl kills process groups info!(count, "pool shutdown complete"); } } + +/// Suspend a connection: save its sessionId to the suspended map and remove +/// from active. The connection is dropped, triggering process group kill. +fn suspend_entry(state: &mut PoolState, thread_id: &str) { + if let Some(conn) = state.active.remove(thread_id) { + if let Some(sid) = &conn.acp_session_id { + info!(thread_id, session_id = %sid, "suspending session"); + state.suspended.insert(thread_id.to_string(), sid.clone()); + } + // conn dropped here → Drop impl kills process group + } +} From c5bfbe8d12eb700d3a82e392b553da85fbb5461b Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:48:22 +0000 Subject: [PATCH 17/30] release: v0.7.3-beta.2 --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index ec4ca5de..aa024464 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.3-beta.1 -appVersion: "0.7.3-beta.1" +version: 0.7.3-beta.2 +appVersion: "0.7.3-beta.2" From fca740d4806ae3117334491d0a41afbbf81bd927 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:20:43 +0000 Subject: [PATCH 18/30] release: v0.7.3 --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index aa024464..2afbd370 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.3-beta.2 -appVersion: "0.7.3-beta.2" +version: 0.7.3 +appVersion: "0.7.3" From eb2c33b488ae8a77393b66b36a6545d5c4abe643 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 14 Apr 2026 13:02:23 +0800 Subject: [PATCH 19/30] fix: bump version to 0.7.3 (merge conflict resolution picked 0.7.2) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b5a9dac4..feb662eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.7.2" +version = "0.7.3" edition = "2021" [dependencies] From 4115f196c84de9bda46a3c6931537f992f1a9f1f Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 14 Apr 2026 06:33:52 +0000 Subject: [PATCH 20/30] feat: add allow_bot_messages config (off/mentions/all) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 3-value enum config option to control bot-to-bot message handling, inspired by Hermes Agent's DISCORD_ALLOW_BOTS and OpenClaw's allowBots: - "off" (default): ignore all bot messages — no behavior change - "mentions": only process bot messages that @mention this bot - "all": process all bot messages, capped at MAX_CONSECUTIVE_BOT_TURNS (10) Safety: self-ignore always applies, "mentions" is a natural loop breaker, "all" uses cache-first history check with fail-closed on API errors. Case-insensitive deserialization, accepts "none"/"false" → off, "true" → all. AllowBots::Off naming avoids confusion with Option::None. Closes #319 --- config.toml.example | 3 +++ src/config.rs | 37 ++++++++++++++++++++++++++++++++++ src/discord.rs | 49 +++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 6 +++++- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/config.toml.example b/config.toml.example index 6b377e5f..60db88f8 100644 --- a/config.toml.example +++ b/config.toml.example @@ -2,6 +2,9 @@ bot_token = "${DISCORD_BOT_TOKEN}" allowed_channels = ["1234567890"] # allowed_users = [""] # empty or omitted = allow all users +# allow_bot_messages = "off" # "off" (default) | "mentions" | "all" + # "mentions" is recommended for multi-agent collaboration +# trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict [agent] command = "kiro-cli" diff --git a/src/config.rs b/src/config.rs index 9855e3a4..658e00b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,34 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::Path; +/// Controls whether the bot processes messages from other Discord bots. +/// +/// Inspired by Hermes Agent's `DISCORD_ALLOW_BOTS` 3-value design: +/// - `Off` (default): ignore all bot messages (safe default, no behavior change) +/// - `Mentions`: only process bot messages that @mention this bot (natural loop breaker) +/// - `All`: process all bot messages (capped at `MAX_CONSECUTIVE_BOT_TURNS`) +/// +/// The bot's own messages are always ignored regardless of this setting. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AllowBots { + #[default] + Off, + Mentions, + All, +} + +impl<'de> Deserialize<'de> for AllowBots { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "off" | "none" | "false" => Ok(Self::Off), + "mentions" => Ok(Self::Mentions), + "all" | "true" => Ok(Self::All), + other => Err(serde::de::Error::unknown_variant(other, &["off", "mentions", "all"])), + } + } +} + #[derive(Debug, Deserialize)] pub struct Config { pub discord: DiscordConfig, @@ -48,6 +76,15 @@ pub struct DiscordConfig { pub allowed_channels: Vec, #[serde(default)] pub allowed_users: Vec, + #[serde(default)] + pub allow_bot_messages: AllowBots, + /// When non-empty, only bot messages from these IDs pass the bot gate. + /// Combines with `allow_bot_messages`: the mode check runs first, then + /// the allowlist filters further. Empty = allow any bot (mode permitting). + /// Only relevant when `allow_bot_messages` is `"mentions"` or `"all"`; + /// ignored when `"off"` since all bot messages are rejected before this check. + #[serde(default)] + pub trusted_bot_ids: Vec, } #[derive(Debug, Deserialize)] diff --git a/src/discord.rs b/src/discord.rs index e267064e..9259753e 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,5 +1,5 @@ use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; -use crate::config::{ReactionsConfig, SttConfig}; +use crate::config::{AllowBots, ReactionsConfig, SttConfig}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::reactions::StatusReactionController; @@ -18,6 +18,11 @@ use std::sync::Arc; use tokio::sync::watch; use tracing::{debug, error, info}; +/// Hard cap on consecutive bot-to-bot turns per thread/channel. +/// Prevents infinite loops when `allow_bot_messages = "all"`. +/// Inspired by OpenClaw's `session.agentToAgent.maxPingPongTurns`. +const MAX_BOT_TURNS_PER_THREAD: usize = 10; + /// Reusable HTTP client for downloading Discord attachments. /// Built once with a 30s timeout and rustls TLS (no native-tls deps). static HTTP_CLIENT: LazyLock = LazyLock::new(|| { @@ -33,17 +38,20 @@ pub struct Handler { pub allowed_users: HashSet, pub reactions_config: ReactionsConfig, pub stt_config: SttConfig, + pub allow_bot_messages: AllowBots, + pub trusted_bot_ids: HashSet, } #[async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { - if msg.author.bot { + let bot_id = ctx.cache.current_user().id; + + // Always ignore own messages + if msg.author.id == bot_id { return; } - let bot_id = ctx.cache.current_user().id; - let channel_id = msg.channel_id.get(); let in_allowed_channel = self.allowed_channels.is_empty() || self.allowed_channels.contains(&channel_id); @@ -52,6 +60,39 @@ impl EventHandler for Handler { || msg.content.contains(&format!("<@{}>", bot_id)) || msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r))); + // Bot message gating — runs after self-ignore but before channel/user + // allowlist checks. This ordering is intentional: channel checks below + // apply uniformly to both human and bot messages, so a bot mention in + // a non-allowed channel is still rejected by the channel check. + if msg.author.bot { + match self.allow_bot_messages { + AllowBots::Off => return, + AllowBots::Mentions => if !is_mentioned { return; }, + AllowBots::All => { + // Safety net: cap consecutive bot messages to prevent + // infinite loops when two bots both use "all" mode. + if let Ok(history) = msg.channel_id + .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_BOT_TURNS_PER_THREAD as u8)) + .await + { + let consecutive_bot = history.iter() + .take_while(|m| m.author.bot && m.author.id != bot_id) + .count(); + if consecutive_bot >= MAX_BOT_TURNS_PER_THREAD { + tracing::warn!(channel_id = %msg.channel_id, cap = MAX_BOT_TURNS_PER_THREAD, "bot turn cap reached, ignoring"); + return; + } + } + }, + } + + // If trusted_bot_ids is set, only allow bots on the list + if !self.trusted_bot_ids.is_empty() && !self.trusted_bot_ids.contains(&msg.author.id.get()) { + tracing::debug!(bot_id = %msg.author.id, "bot not in trusted_bot_ids, ignoring"); + return; + } + } + let in_thread = if !in_allowed_channel { match msg.channel_id.to_channel(&ctx.http).await { Ok(serenity::model::channel::Channel::Guild(gc)) => { diff --git a/src/main.rs b/src/main.rs index 225bf236..fd63b89a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ async fn main() -> anyhow::Result<()> { channels = ?cfg.discord.allowed_channels, users = ?cfg.discord.allowed_users, reactions = cfg.reactions.enabled, + allow_bot_messages = ?cfg.discord.allow_bot_messages, "config loaded" ); @@ -41,7 +42,8 @@ async fn main() -> anyhow::Result<()> { let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; - info!(channels = allowed_channels.len(), users = allowed_users.len(), "parsed allowlists"); + let trusted_bot_ids = parse_id_set(&cfg.discord.trusted_bot_ids, "trusted_bot_ids")?; + info!(channels = allowed_channels.len(), users = allowed_users.len(), trusted_bots = ?trusted_bot_ids, "parsed allowlists"); // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) if cfg.stt.enabled { @@ -65,6 +67,8 @@ async fn main() -> anyhow::Result<()> { allowed_users, reactions_config: cfg.reactions, stt_config: cfg.stt.clone(), + allow_bot_messages: cfg.discord.allow_bot_messages, + trusted_bot_ids, }; let intents = GatewayIntents::GUILD_MESSAGES From 9ca3afb46e668b5029117563cf5b2fc9d0ca4864 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 14 Apr 2026 06:43:49 +0000 Subject: [PATCH 21/30] docs: add bot-to-bot communication section to multi-agent.md --- docs/multi-agent.md | 74 +++++++++++++++++++++++++++++++++++++++++++++ src/discord.rs | 66 +++++++++++++++++++++++++++++++--------- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/multi-agent.md b/docs/multi-agent.md index c28c2f30..f228c346 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -51,3 +51,77 @@ See individual agent docs for authentication steps: - [Claude Code](claude-code.md) - [Codex](codex.md) - [Gemini](gemini.md) + +## Bot-to-Bot Communication + +By default, each agent ignores messages from other bots. To enable multi-agent collaboration in the same channel (e.g. a code review bot handing off to a deploy bot), configure `allow_bot_messages` in each agent's `config.toml`: + +```toml +[discord] +allow_bot_messages = "mentions" # recommended +``` + +### Modes + +| Value | Behavior | Loop risk | +|---|---|---| +| `"off"` (default) | Ignore all bot messages | None | +| `"mentions"` | Only respond to bot messages that @mention this bot | Very low — bots must explicitly @mention each other | +| `"all"` | Respond to all bot messages | Mitigated by turn cap (10 consecutive bot messages) | + +### Which mode should I use? + +**`"mentions"` is recommended for most setups.** It enables collaboration while acting as a natural loop breaker — Bot A only processes Bot B's message if Bot B explicitly @mentions Bot A. Two bots won't accidentally ping-pong. + +Use `"all"` only when bots need to react to each other's messages without explicit mentions (e.g. monitoring bots). A hard cap of 10 consecutive bot-to-bot turns prevents infinite loops. + +### Example: Code Review → Deploy handoff + +``` +┌──────────────────────────────────────────────────────────┐ +│ Discord Channel #dev │ +│ │ +│ 👤 User: "Review this PR and deploy if it looks good" │ +│ │ │ +│ ▼ │ +│ 🤖 Kiro (allow_bot_messages = "off"): │ +│ "LGTM — tests pass, no security issues. │ +│ @DeployBot please deploy to staging." │ +│ │ │ +│ ▼ │ +│ 🤖 Deploy Bot (allow_bot_messages = "mentions"): │ +│ "Deploying to staging... ✅ Done." │ +└──────────────────────────────────────────────────────────┘ +``` + +Note: the review bot doesn't need `allow_bot_messages` enabled — only the bot that needs to *receive* bot messages does. + +### Helm values + +```bash +helm install openab openab/openab \ + --set agents.kiro.discord.botToken="$KIRO_BOT_TOKEN" \ + --set agents.kiro.discord.allowBotMessages="off" \ + --set agents.deploy.discord.botToken="$DEPLOY_BOT_TOKEN" \ + --set agents.deploy.discord.allowBotMessages="mentions" +``` + +### Safety + +- The bot's own messages are **always** ignored, regardless of setting +- `"mentions"` mode is a natural loop breaker — no rate limiter needed +- `"all"` mode has a hard cap of 10 consecutive bot-to-bot turns per channel +- Channel and user allowlists still apply to bot messages +- `trusted_bot_ids` further restricts which bots are allowed through + +### Restricting to specific bots + +If you only want to accept messages from specific bots (e.g. your own deploy bot), add their Discord user IDs: + +```toml +[discord] +allow_bot_messages = "mentions" +trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass through +``` + +When `trusted_bot_ids` is empty (default), any bot can pass through (subject to the mode check). When set, only listed bots are accepted — all others are silently ignored. diff --git a/src/discord.rs b/src/discord.rs index 9259753e..7677bc47 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -18,10 +18,14 @@ use std::sync::Arc; use tokio::sync::watch; use tracing::{debug, error, info}; -/// Hard cap on consecutive bot-to-bot turns per thread/channel. -/// Prevents infinite loops when `allow_bot_messages = "all"`. +/// Hard cap on consecutive bot messages (from any other bot) in a +/// channel or thread. When this many recent messages are all from +/// bots other than ourselves, we stop responding to prevent runaway +/// loops between multiple bots in "all" mode. +/// +/// Note: must be ≤ 255 because Serenity's `GetMessages::limit()` takes `u8`. /// Inspired by OpenClaw's `session.agentToAgent.maxPingPongTurns`. -const MAX_BOT_TURNS_PER_THREAD: usize = 10; +const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10; /// Reusable HTTP client for downloading Discord attachments. /// Built once with a 30s timeout and rustls TLS (no native-tls deps). @@ -69,19 +73,51 @@ impl EventHandler for Handler { AllowBots::Off => return, AllowBots::Mentions => if !is_mentioned { return; }, AllowBots::All => { - // Safety net: cap consecutive bot messages to prevent - // infinite loops when two bots both use "all" mode. - if let Ok(history) = msg.channel_id - .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_BOT_TURNS_PER_THREAD as u8)) - .await - { - let consecutive_bot = history.iter() - .take_while(|m| m.author.bot && m.author.id != bot_id) - .count(); - if consecutive_bot >= MAX_BOT_TURNS_PER_THREAD { - tracing::warn!(channel_id = %msg.channel_id, cap = MAX_BOT_TURNS_PER_THREAD, "bot turn cap reached, ignoring"); - return; + // Safety net: count consecutive messages from any bot + // (excluding ourselves) in recent history. If all recent + // messages are from other bots, we've likely entered a + // loop. This counts *all* other-bot messages, not just + // one specific bot — so 3 bots taking turns still hits + // the cap (which is intentionally conservative). + // + // Try cache first to avoid an API call on every bot + // message. Fall back to API on cache miss. If both fail, + // reject the message (fail-closed) to avoid unbounded + // loops during Discord API outages. + let cap = MAX_CONSECUTIVE_BOT_TURNS as usize; + let history = ctx.cache.channel_messages(msg.channel_id) + .map(|msgs| { + let mut recent: Vec<_> = msgs.iter() + .filter(|(mid, _)| **mid < msg.id) + .map(|(_, m)| m.clone()) + .collect(); + recent.sort_unstable_by(|a, b| b.id.cmp(&a.id)); // newest first + recent.truncate(cap); + recent + }) + .filter(|msgs| !msgs.is_empty()); + + let recent = if let Some(cached) = history { + cached + } else { + match msg.channel_id + .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_CONSECUTIVE_BOT_TURNS)) + .await + { + Ok(msgs) => msgs, + Err(e) => { + tracing::warn!(channel_id = %msg.channel_id, error = %e, "failed to fetch history for bot turn cap, rejecting (fail-closed)"); + return; + } } + }; + + let consecutive_bot = recent.iter() + .take_while(|m| m.author.bot && m.author.id != bot_id) + .count(); + if consecutive_bot >= cap { + tracing::warn!(channel_id = %msg.channel_id, cap, "bot turn cap reached, ignoring"); + return; } }, } From 82fc2170f4fedf02a21a61805f95d1f5a39579c0 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:20:32 +0000 Subject: [PATCH 22/30] release: v0.7.4-beta.1 --- Cargo.toml | 2 +- charts/openab/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 829d7bd1..26abc396 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.7.3" +version = "0.7.4" edition = "2021" [dependencies] diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 2afbd370..a3a0fba1 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.3 -appVersion: "0.7.3" +version: 0.7.4-beta.1 +appVersion: "0.7.4-beta.1" From ca4b7485312e8653f5e1670c5f2e47147095d5bf Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 17:20:05 +0800 Subject: [PATCH 23/30] feat(helm): add allowBotMessages and trustedBotIds to discord config Add support for multi-agent collaboration via bot message handling: - allowBotMessages: "off" | "mentions" | "all" (default: off) - trustedBotIds: list of bot IDs allowed through (default: any) Fixes: helm chart 0.7.x template ignored these fields when set --- charts/openab/templates/configmap.yaml | 6 ++++++ charts/openab/values.yaml | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 194d8c25..b9e1c766 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -24,6 +24,12 @@ data: {{- end }} {{- end }} allowed_users = {{ $cfg.discord.allowedUsers | default list | toJson }} + {{- if $cfg.discord.allowBotMessages }} + allow_bot_messages = "{{ $cfg.discord.allowBotMessages }}" + {{- end }} + {{- if $cfg.discord.trustedBotIds }} + trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} + {{- end }} [agent] command = "{{ $cfg.command }}" diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 956374cb..b3c6d42f 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -29,6 +29,11 @@ agents: # allowedChannels: # - "YOUR_CHANNEL_ID" # allowedUsers: [] + # # allow_bot_messages: "off" (default) | "mentions" | "all" + # # recommended for multi-agent collaboration + # allowBotMessages: "off" + # # trusted_bot_ids: [] # empty = any bot (mode permitting) + # trustedBotIds: [] # workingDir: /home/agent # env: {} # envFrom: [] @@ -60,6 +65,11 @@ agents: - "YOUR_CHANNEL_ID" # ⚠️ Use --set-string for user IDs to avoid float64 precision loss allowedUsers: [] # empty = allow all users (default) + # allow_bot_messages: "off" (default) | "mentions" | "all" + # recommended for multi-agent collaboration + allowBotMessages: "off" + # trusted_bot_ids: [] # empty = any bot (mode permitting); set to restrict + trustedBotIds: [] workingDir: /home/agent env: {} envFrom: [] From 1abd1d26c9816296b62b9d3e92ded192e695e370 Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 17:51:09 +0800 Subject: [PATCH 24/30] fix: add snowflake ID validation for trustedBotIds Add regexMatch validation for trustedBotIds (same as allowedChannels/allowedUsers) to prevent float64 precision loss when bot IDs are passed via --set instead of --set-string. Also fix comments in values.yaml to use camelCase key names (allowBotMessages/trustedBotIds) instead of TOML output names. --- charts/openab/templates/configmap.yaml | 5 +++++ charts/openab/values.yaml | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index b9e1c766..a513346a 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -27,6 +27,11 @@ data: {{- if $cfg.discord.allowBotMessages }} allow_bot_messages = "{{ $cfg.discord.allowBotMessages }}" {{- end }} + {{- range $cfg.discord.trustedBotIds }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "discord.trustedBotIds contains a mangled ID: %s — use --set-string instead of --set for bot IDs" (toString .)) }} + {{- end }} + {{- end }} {{- if $cfg.discord.trustedBotIds }} trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} {{- end }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index b3c6d42f..4cb6bacd 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -29,10 +29,10 @@ agents: # allowedChannels: # - "YOUR_CHANNEL_ID" # allowedUsers: [] - # # allow_bot_messages: "off" (default) | "mentions" | "all" + # # allowBotMessages: "off" (default) | "mentions" | "all" # # recommended for multi-agent collaboration # allowBotMessages: "off" - # # trusted_bot_ids: [] # empty = any bot (mode permitting) + # # trustedBotIds: [] # empty = any bot (mode permitting) # trustedBotIds: [] # workingDir: /home/agent # env: {} @@ -65,10 +65,10 @@ agents: - "YOUR_CHANNEL_ID" # ⚠️ Use --set-string for user IDs to avoid float64 precision loss allowedUsers: [] # empty = allow all users (default) - # allow_bot_messages: "off" (default) | "mentions" | "all" + # allowBotMessages: "off" (default) | "mentions" | "all" # recommended for multi-agent collaboration allowBotMessages: "off" - # trusted_bot_ids: [] # empty = any bot (mode permitting); set to restrict + # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict trustedBotIds: [] workingDir: /home/agent env: {} From e5d3fa2a9d27a9de135981781e584cac14991857 Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 17:56:52 +0800 Subject: [PATCH 25/30] fix(helm): add allowBotMessages enum validation and helm template tests Reject invalid allowBotMessages values at template time (must be off, mentions, or all). Add test script covering rendering, enum validation, and snowflake ID mangling detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/configmap.yaml | 3 + charts/openab/tests/helm-template-test.sh | 95 +++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100755 charts/openab/tests/helm-template-test.sh diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index a513346a..1deac66f 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -25,6 +25,9 @@ data: {{- end }} allowed_users = {{ $cfg.discord.allowedUsers | default list | toJson }} {{- if $cfg.discord.allowBotMessages }} + {{- if not (has $cfg.discord.allowBotMessages (list "off" "mentions" "all")) }} + {{- fail (printf "agents.%s.discord.allowBotMessages must be one of: off, mentions, all — got: %s" $name $cfg.discord.allowBotMessages) }} + {{- end }} allow_bot_messages = "{{ $cfg.discord.allowBotMessages }}" {{- end }} {{- range $cfg.discord.trustedBotIds }} diff --git a/charts/openab/tests/helm-template-test.sh b/charts/openab/tests/helm-template-test.sh new file mode 100755 index 00000000..54fb8a2c --- /dev/null +++ b/charts/openab/tests/helm-template-test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Helm template tests for openab chart — bot messages config +set -euo pipefail + +CHART_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PASS=0 +FAIL=0 + +pass() { ((PASS++)); echo " PASS: $1"; } +fail() { ((FAIL++)); echo " FAIL: $1"; } + +echo "=== Helm template tests: allowBotMessages & trustedBotIds ===" +echo + +# ---------- Test 1: allowBotMessages = "mentions" renders correctly ---------- +echo "[Test 1] allowBotMessages = mentions renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=mentions' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "mentions"'; then + pass "allow_bot_messages = \"mentions\" found in rendered output" +else + fail "allow_bot_messages = \"mentions\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 2: allowBotMessages = "all" renders correctly ---------- +echo "[Test 2] allowBotMessages = all renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=all' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "all"'; then + pass "allow_bot_messages = \"all\" found in rendered output" +else + fail "allow_bot_messages = \"all\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 3: allowBotMessages = "off" renders correctly ---------- +echo "[Test 3] allowBotMessages = off renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=off' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "off"'; then + pass "allow_bot_messages = \"off\" found in rendered output" +else + fail "allow_bot_messages = \"off\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 4: invalid allowBotMessages value fails ---------- +echo "[Test 4] invalid allowBotMessages value is rejected" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=yolo' 2>&1) && RC=0 || RC=$? +if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q 'must be one of: off, mentions, all'; then + pass "invalid value 'yolo' rejected with correct error message" +else + fail "invalid value 'yolo' was not rejected or error message is wrong" + echo "$OUT" +fi + +# ---------- Test 5: trustedBotIds renders correctly ---------- +echo "[Test 5] trustedBotIds renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set-string 'agents.kiro.discord.trustedBotIds[0]=123456789012345678' \ + --set-string 'agents.kiro.discord.trustedBotIds[1]=987654321098765432' \ + --set 'agents.kiro.discord.allowBotMessages=mentions' 2>&1) +if echo "$OUT" | grep -q 'trusted_bot_ids = \["123456789012345678","987654321098765432"\]'; then + pass "trustedBotIds rendered as JSON array" +else + fail "trustedBotIds not rendered correctly" + echo "$OUT" +fi + +# ---------- Test 6: mangled trustedBotId (--set not --set-string) fails ---------- +echo "[Test 6] mangled snowflake ID via --set is rejected" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.trustedBotIds[0]=1.234567890123457e+17' 2>&1) && RC=0 || RC=$? +if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q 'mangled ID'; then + pass "mangled snowflake ID rejected with correct error" +else + fail "mangled snowflake ID was not rejected" + echo "$OUT" +fi + +# ---------- Test 7: default allowBotMessages="off" does not omit the field ---------- +echo "[Test 7] default values render allow_bot_messages" +OUT=$(helm template test "$CHART_DIR" 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "off"'; then + pass "default allow_bot_messages = \"off\" rendered" +else + # With default "off" the template still renders it since the value is set + pass "allow_bot_messages present in default render (or omitted by design)" +fi + +echo +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1 From 0df81eb631de82bf6ff30b5d22338bbdc015dca6 Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 18:11:00 +0800 Subject: [PATCH 26/30] fix(helm): fix always-passing test and add positive snowflake validation Test 7 else-branch unconditionally called pass, masking failures. Add ^[0-9]{17,20}$ regex check for trustedBotIds so non-numeric values like "not-a-snowflake" are rejected. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/configmap.yaml | 3 +++ charts/openab/tests/helm-template-test.sh | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 1deac66f..cb7dce89 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -34,6 +34,9 @@ data: {{- if regexMatch "e\\+|E\\+" (toString .) }} {{- fail (printf "discord.trustedBotIds contains a mangled ID: %s — use --set-string instead of --set for bot IDs" (toString .)) }} {{- end }} + {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }} + {{- fail (printf "discord.trustedBotIds contains an invalid bot ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }} + {{- end }} {{- end }} {{- if $cfg.discord.trustedBotIds }} trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} diff --git a/charts/openab/tests/helm-template-test.sh b/charts/openab/tests/helm-template-test.sh index 54fb8a2c..d3f74f81 100755 --- a/charts/openab/tests/helm-template-test.sh +++ b/charts/openab/tests/helm-template-test.sh @@ -86,8 +86,8 @@ OUT=$(helm template test "$CHART_DIR" 2>&1) if echo "$OUT" | grep -q 'allow_bot_messages = "off"'; then pass "default allow_bot_messages = \"off\" rendered" else - # With default "off" the template still renders it since the value is set - pass "allow_bot_messages present in default render (or omitted by design)" + fail "default allow_bot_messages = \"off\" not found in rendered output" + echo "$OUT" fi echo From 8475d68cd40595b209ea763bf8fdae9d32061946 Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 18:16:12 +0800 Subject: [PATCH 27/30] fix: remove duplicate comment markers in values.yaml example block Fix double comment markers (##) that appeared in the commented-out claude agent example block for allowBotMessages and trustedBotIds. --- charts/openab/values.yaml.bak | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 charts/openab/values.yaml.bak diff --git a/charts/openab/values.yaml.bak b/charts/openab/values.yaml.bak new file mode 100644 index 00000000..4cb6bacd --- /dev/null +++ b/charts/openab/values.yaml.bak @@ -0,0 +1,95 @@ +image: + repository: ghcr.io/openabdev/openab + # tag defaults to .Chart.AppVersion + tag: "" + pullPolicy: IfNotPresent + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + +containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +agents: + kiro: + enabled: true # set to false to skip creating resources for this agent + # To add a second agent, uncomment and fill in the block below: + # claude: + # command: claude-agent-acp + # args: [] + # discord: + # botToken: "" + # # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # # allowBotMessages: "off" (default) | "mentions" | "all" + # # recommended for multi-agent collaboration + # allowBotMessages: "off" + # # trustedBotIds: [] # empty = any bot (mode permitting) + # trustedBotIds: [] + # workingDir: /home/agent + # env: {} + # envFrom: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # reactions: + # enabled: true + # removeAfterReply: false + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # agentsMd: "" + # resources: {} + # nodeSelector: {} + # tolerations: [] + # affinity: {} + # image: "ghcr.io/openabdev/openab-claude:latest" + image: "" + command: kiro-cli + args: + - acp + - --trust-all-tools + discord: + botToken: "" + # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss + allowedChannels: + - "YOUR_CHANNEL_ID" + # ⚠️ Use --set-string for user IDs to avoid float64 precision loss + allowedUsers: [] # empty = allow all users (default) + # allowBotMessages: "off" (default) | "mentions" | "all" + # recommended for multi-agent collaboration + allowBotMessages: "off" + # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict + trustedBotIds: [] + workingDir: /home/agent + env: {} + envFrom: [] + pool: + maxSessions: 10 + sessionTtlHours: 24 + reactions: + enabled: true + removeAfterReply: false + stt: + enabled: false + apiKey: "" + model: "whisper-large-v3-turbo" + baseUrl: "https://api.groq.com/openai/v1" + persistence: + enabled: true + storageClass: "" + size: 1Gi # defaults to 1Gi if not set + agentsMd: "" + resources: {} + nodeSelector: {} + tolerations: [] + affinity: {} From 90e8f2bba5591229cb6eaccdd0c9e961fe01a63c Mon Sep 17 00:00:00 2001 From: Openbot Date: Tue, 14 Apr 2026 18:16:45 +0800 Subject: [PATCH 28/30] chore: remove accidental .bak file --- charts/openab/values.yaml.bak | 95 ----------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 charts/openab/values.yaml.bak diff --git a/charts/openab/values.yaml.bak b/charts/openab/values.yaml.bak deleted file mode 100644 index 4cb6bacd..00000000 --- a/charts/openab/values.yaml.bak +++ /dev/null @@ -1,95 +0,0 @@ -image: - repository: ghcr.io/openabdev/openab - # tag defaults to .Chart.AppVersion - tag: "" - pullPolicy: IfNotPresent - -podSecurityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - -containerSecurityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - -agents: - kiro: - enabled: true # set to false to skip creating resources for this agent - # To add a second agent, uncomment and fill in the block below: - # claude: - # command: claude-agent-acp - # args: [] - # discord: - # botToken: "" - # # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss - # allowedChannels: - # - "YOUR_CHANNEL_ID" - # allowedUsers: [] - # # allowBotMessages: "off" (default) | "mentions" | "all" - # # recommended for multi-agent collaboration - # allowBotMessages: "off" - # # trustedBotIds: [] # empty = any bot (mode permitting) - # trustedBotIds: [] - # workingDir: /home/agent - # env: {} - # envFrom: [] - # pool: - # maxSessions: 10 - # sessionTtlHours: 24 - # reactions: - # enabled: true - # removeAfterReply: false - # persistence: - # enabled: true - # storageClass: "" - # size: 1Gi - # agentsMd: "" - # resources: {} - # nodeSelector: {} - # tolerations: [] - # affinity: {} - # image: "ghcr.io/openabdev/openab-claude:latest" - image: "" - command: kiro-cli - args: - - acp - - --trust-all-tools - discord: - botToken: "" - # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss - allowedChannels: - - "YOUR_CHANNEL_ID" - # ⚠️ Use --set-string for user IDs to avoid float64 precision loss - allowedUsers: [] # empty = allow all users (default) - # allowBotMessages: "off" (default) | "mentions" | "all" - # recommended for multi-agent collaboration - allowBotMessages: "off" - # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict - trustedBotIds: [] - workingDir: /home/agent - env: {} - envFrom: [] - pool: - maxSessions: 10 - sessionTtlHours: 24 - reactions: - enabled: true - removeAfterReply: false - stt: - enabled: false - apiKey: "" - model: "whisper-large-v3-turbo" - baseUrl: "https://api.groq.com/openai/v1" - persistence: - enabled: true - storageClass: "" - size: 1Gi # defaults to 1Gi if not set - agentsMd: "" - resources: {} - nodeSelector: {} - tolerations: [] - affinity: {} From 7e8819982a8c59880ff5dcef7bd3181f90046ca2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 14 Apr 2026 11:05:59 +0000 Subject: [PATCH 29/30] fix: pin CLI versions in all Dockerfiles using ARG for reproducible builds - Dockerfile: pin kiro-cli to 2.0.0 (use prod.download.cli.kiro.dev) - Dockerfile.codex: pin @openai/codex to 0.120.0 - Dockerfile.claude: pin @anthropic-ai/claude-code to 2.1.107 - Dockerfile.gemini: pin @google/gemini-cli to 0.37.2 - Dockerfile.copilot: pin @github/copilot to 1.0.25 Kiro CLI version can be checked via: curl -fsSL https://prod.download.cli.kiro.dev/stable/latest/manifest.json | jq -r '.version' Closes #325 --- Dockerfile | 5 +++-- Dockerfile.claude | 3 ++- Dockerfile.codex | 3 ++- Dockerfile.copilot | 3 ++- Dockerfile.gemini | 3 ++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index fdb14e2a..600b4680 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,10 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip && rm -rf /var/lib/apt/lists/* # Install kiro-cli (auto-detect arch, copy binary directly) +ARG KIRO_CLI_VERSION=2.0.0 RUN ARCH=$(dpkg --print-architecture) && \ - if [ "$ARCH" = "arm64" ]; then URL="https://desktop-release.q.us-east-1.amazonaws.com/latest/kirocli-aarch64-linux.zip"; \ - else URL="https://desktop-release.q.us-east-1.amazonaws.com/latest/kirocli-x86_64-linux.zip"; fi && \ + if [ "$ARCH" = "arm64" ]; then URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-aarch64-linux.zip"; \ + else URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-x86_64-linux.zip"; fi && \ curl --proto '=https' --tlsv1.2 -sSf --retry 3 --retry-delay 5 "$URL" -o /tmp/kirocli.zip && \ unzip /tmp/kirocli.zip -d /tmp && \ cp /tmp/kirocli/bin/* /usr/local/bin/ && \ diff --git a/Dockerfile.claude b/Dockerfile.claude index 2c8b90ab..da12d8bb 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install claude-agent-acp adapter and Claude Code CLI -RUN npm install -g @agentclientprotocol/claude-agent-acp@0.25.0 @anthropic-ai/claude-code --retry 3 +ARG CLAUDE_CODE_VERSION=2.1.107 +RUN npm install -g @agentclientprotocol/claude-agent-acp@0.25.0 @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.codex b/Dockerfile.codex index b7ab4921..198b8cb0 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally -RUN npm install -g @zed-industries/codex-acp@0.9.5 @openai/codex --retry 3 +ARG CODEX_VERSION=0.120.0 +RUN npm install -g @zed-industries/codex-acp@0.9.5 @openai/codex@${CODEX_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.copilot b/Dockerfile.copilot index ca9bcc67..c164a429 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install GitHub Copilot CLI via npm (pinned version) -RUN npm install -g @github/copilot@1 --retry 3 +ARG COPILOT_VERSION=1.0.25 +RUN npm install -g @github/copilot@${COPILOT_VERSION} --retry 3 # Install gh CLI (for auth and token management) RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.gemini b/Dockerfile.gemini index a5ce9201..d2230547 100644 --- a/Dockerfile.gemini +++ b/Dockerfile.gemini @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install Gemini CLI (native ACP support via --acp) -RUN npm install -g @google/gemini-cli --retry 3 +ARG GEMINI_CLI_VERSION=0.37.2 +RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ From 0f50a3692aba5ed089a8b732b09c52d233c0c57a Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:15:50 +0000 Subject: [PATCH 30/30] release: v0.7.4-beta.2 --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index a3a0fba1..83157741 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.4-beta.1 -appVersion: "0.7.4-beta.1" +version: 0.7.4-beta.2 +appVersion: "0.7.4-beta.2"