use std::io::{self, Write};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio_tungstenite::connect_async;
use zeptoclaw::config::Config;
use super::common::read_line;
use super::ChannelAction;
pub(crate) async fn cmd_channel(action: ChannelAction) -> Result<()> {
match action {
ChannelAction::List => cmd_channel_list().await,
ChannelAction::Setup { channel_name } => cmd_channel_setup(&channel_name).await,
ChannelAction::Test { channel_name } => cmd_channel_test(&channel_name).await,
}
}
async fn cmd_channel_list() -> Result<()> {
let config = Config::load().unwrap_or_default();
println!("Channels:");
let (tg_status, tg_detail) = match config.channels.telegram {
Some(ref c) if c.enabled => (
"enabled",
if c.token.is_empty() {
"token missing".to_string()
} else {
"token configured".to_string()
},
),
_ => ("disabled", "-".to_string()),
};
println!(" {:<12} {:<10} {}", "telegram", tg_status, tg_detail);
let (dc_status, dc_detail) = match config.channels.discord {
Some(ref c) if c.enabled => (
"enabled",
if c.token.is_empty() {
"token missing".to_string()
} else {
"token configured".to_string()
},
),
_ => ("disabled", "-".to_string()),
};
println!(" {:<12} {:<10} {}", "discord", dc_status, dc_detail);
let (sl_status, sl_detail) = match config.channels.slack {
Some(ref c) if c.enabled => (
"enabled",
if c.bot_token.is_empty() {
"token missing".to_string()
} else {
"token configured".to_string()
},
),
_ => ("disabled", "-".to_string()),
};
println!(" {:<12} {:<10} {}", "slack", sl_status, sl_detail);
let (wa_status, wa_detail) = match config.channels.whatsapp {
Some(ref c) if c.enabled => ("enabled", format!("bridge: {}", c.bridge_url)),
_ => ("disabled", "-".to_string()),
};
println!(" {:<12} {:<10} {}", "whatsapp", wa_status, wa_detail);
let (wh_status, wh_detail) = match config.channels.webhook {
Some(ref c) if c.enabled => (
"enabled",
format!("{}:{}{}", c.bind_address, c.port, c.path),
),
_ => ("disabled", "-".to_string()),
};
println!(" {:<12} {:<10} {}", "webhook", wh_status, wh_detail);
Ok(())
}
const KNOWN_CHANNELS: &[&str] = &["telegram", "discord", "slack", "whatsapp", "webhook"];
async fn cmd_channel_setup(channel_name: &str) -> Result<()> {
if !KNOWN_CHANNELS.contains(&channel_name) {
anyhow::bail!(
"Unknown channel '{}'. Known channels: {}",
channel_name,
KNOWN_CHANNELS.join(", ")
);
}
let mut config = Config::load().unwrap_or_default();
match channel_name {
"whatsapp" => setup_whatsapp(&mut config)?,
"telegram" => {
println!("Use 'zeptoclaw onboard' to configure Telegram.");
return Ok(());
}
"discord" => {
println!("Use 'zeptoclaw onboard' to configure Discord.");
return Ok(());
}
"slack" => {
println!("Use 'zeptoclaw onboard' to configure Slack.");
return Ok(());
}
"webhook" => {
println!("Use 'zeptoclaw onboard' to configure Webhook.");
return Ok(());
}
_ => unreachable!(),
}
config
.save()
.with_context(|| "Failed to save configuration")?;
Ok(())
}
fn setup_whatsapp(config: &mut Config) -> Result<()> {
println!();
println!("WhatsApp Channel Setup (via Bridge)");
println!("-----------------------------------");
println!("Requires whatsmeow-rs bridge: https://github.com/qhkm/whatsmeow-rs");
println!();
let whatsapp_config = config
.channels
.whatsapp
.get_or_insert_with(Default::default);
print!("Enable WhatsApp channel? [y/N]: ");
io::stdout().flush()?;
let enabled = read_line()?.to_ascii_lowercase();
if !matches!(enabled.as_str(), "y" | "yes") {
whatsapp_config.enabled = false;
println!(" WhatsApp channel disabled.");
return Ok(());
}
whatsapp_config.enabled = true;
print!("Bridge WebSocket URL [{}]: ", whatsapp_config.bridge_url);
io::stdout().flush()?;
let bridge_url = read_line()?;
if !bridge_url.is_empty() {
whatsapp_config.bridge_url = bridge_url;
}
print!("Phone number allowlist (comma-separated, or Enter for all): ");
io::stdout().flush()?;
let allowlist = read_line()?;
if !allowlist.is_empty() {
whatsapp_config.allow_from = allowlist
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
println!(
" WhatsApp channel configured (bridge: {}).",
whatsapp_config.bridge_url
);
Ok(())
}
async fn cmd_channel_test(channel_name: &str) -> Result<()> {
if !KNOWN_CHANNELS.contains(&channel_name) {
anyhow::bail!(
"Unknown channel '{}'. Known channels: {}",
channel_name,
KNOWN_CHANNELS.join(", ")
);
}
let config = Config::load().unwrap_or_default();
match channel_name {
"whatsapp" => test_whatsapp(&config).await,
"telegram" => {
println!("Telegram test: not yet implemented (use BotFather /getMe).");
Ok(())
}
"discord" => {
println!("Discord test: not yet implemented (use Discord API /gateway).");
Ok(())
}
"slack" => {
println!("Slack test: not yet implemented (use Slack auth.test).");
Ok(())
}
"webhook" => {
println!("Webhook test: not yet implemented (start server and POST to it).");
Ok(())
}
_ => unreachable!(),
}
}
async fn test_whatsapp(config: &Config) -> Result<()> {
let bridge_url = match config.channels.whatsapp {
Some(ref c) if c.enabled => {
if c.bridge_url.is_empty() {
anyhow::bail!("WhatsApp channel enabled but bridge_url is empty");
}
c.bridge_url.clone()
}
Some(_) => {
anyhow::bail!(
"WhatsApp channel is not enabled. Run 'zeptoclaw channel setup whatsapp' first."
);
}
None => {
anyhow::bail!(
"WhatsApp channel not configured. Run 'zeptoclaw channel setup whatsapp' first."
);
}
};
println!("Testing WhatsApp bridge connection to {}...", bridge_url);
match tokio::time::timeout(Duration::from_secs(5), connect_async(&bridge_url)).await {
Ok(Ok((_ws_stream, _))) => {
println!("WhatsApp bridge reachable at {}", bridge_url);
}
Ok(Err(e)) => {
println!("Failed to connect to WhatsApp bridge: {}", e);
}
Err(_) => {
println!(
"Connection timed out after 5 seconds. Is the bridge running at {}?",
bridge_url
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_channels_contains_expected() {
assert!(KNOWN_CHANNELS.contains(&"telegram"));
assert!(KNOWN_CHANNELS.contains(&"discord"));
assert!(KNOWN_CHANNELS.contains(&"slack"));
assert!(KNOWN_CHANNELS.contains(&"whatsapp"));
assert!(KNOWN_CHANNELS.contains(&"webhook"));
}
#[test]
fn test_known_channels_rejects_unknown() {
assert!(!KNOWN_CHANNELS.contains(&"irc"));
assert!(!KNOWN_CHANNELS.contains(&"sms"));
}
#[tokio::test]
async fn test_channel_list_does_not_panic() {
let result = cmd_channel_list().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_channel_setup_unknown_channel() {
let result = cmd_channel_setup("irc").await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown channel"));
assert!(err_msg.contains("irc"));
}
#[tokio::test]
async fn test_channel_test_unknown_channel() {
let result = cmd_channel_test("sms").await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown channel"));
}
#[tokio::test]
async fn test_channel_test_whatsapp_not_configured() {
let config = Config::default();
let result = test_whatsapp(&config).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not configured"));
}
#[tokio::test]
async fn test_channel_test_whatsapp_disabled() {
let mut config = Config::default();
config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig {
enabled: false,
bridge_url: "ws://localhost:3001".to_string(),
bridge_token: None,
allow_from: vec![],
bridge_managed: true,
deny_by_default: true,
});
let result = test_whatsapp(&config).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not enabled"));
}
#[tokio::test]
async fn test_channel_test_whatsapp_empty_url() {
let mut config = Config::default();
config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig {
enabled: true,
bridge_url: String::new(),
bridge_token: None,
allow_from: vec![],
bridge_managed: true,
deny_by_default: true,
});
let result = test_whatsapp(&config).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("bridge_url is empty"));
}
}