From 987e532eb2cf72206fb1c796d2518c70be72c8c7 Mon Sep 17 00:00:00 2001 From: Openbot Date: Sun, 12 Apr 2026 22:33:11 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 eb2c33b488ae8a77393b66b36a6545d5c4abe643 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 14 Apr 2026 13:02:23 +0800 Subject: [PATCH 12/12] 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]