use std::sync::Arc;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
#[cfg(feature = "postgres")]
use crate::secrets::SecretsCrypto;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::settings::{Settings, TunnelSettings};
use crate::setup::prompts::{
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
};
#[derive(Debug, thiserror::Error)]
pub enum ChannelSetupError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Network(String),
#[error("{0}")]
Secrets(String),
#[error("{0}")]
Validation(String),
}
pub struct SecretsContext {
store: Arc<dyn SecretsStore>,
user_id: String,
}
impl SecretsContext {
pub fn from_store(store: Arc<dyn SecretsStore>, user_id: &str) -> Self {
Self {
store,
user_id: user_id.to_string(),
}
}
#[cfg(feature = "postgres")]
pub fn new(pool: deadpool_postgres::Pool, crypto: Arc<SecretsCrypto>, user_id: &str) -> Self {
Self {
store: Arc::new(crate::secrets::PostgresSecretsStore::new(pool, crypto)),
user_id: user_id.to_string(),
}
}
pub async fn save_secret(
&self,
name: &str,
value: &SecretString,
) -> Result<(), ChannelSetupError> {
let params = CreateSecretParams::new(name, value.expose_secret());
self.store
.create(&self.user_id, params)
.await
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to save secret: {}", e)))?;
Ok(())
}
pub async fn secret_exists(&self, name: &str) -> bool {
match self.store.exists(&self.user_id, name).await {
Ok(exists) => exists,
Err(e) => {
tracing::warn!(secret = name, error = %e, "Failed to check if secret exists, assuming absent");
false
}
}
}
pub async fn get_secret(&self, name: &str) -> Result<SecretString, ChannelSetupError> {
let decrypted = self
.store
.get_decrypted(&self.user_id, name)
.await
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to read secret: {}", e)))?;
Ok(SecretString::from(decrypted.expose().to_string()))
}
}
#[derive(Debug, Clone)]
pub struct TelegramSetupResult {
pub enabled: bool,
pub bot_username: Option<String>,
pub webhook_secret: Option<String>,
pub owner_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct TelegramGetMeResponse {
ok: bool,
result: Option<TelegramUser>,
}
#[derive(Debug, Deserialize)]
struct TelegramUser {
username: Option<String>,
#[allow(dead_code)]
first_name: String,
}
#[derive(Debug, Deserialize)]
struct TelegramGetUpdatesResponse {
ok: bool,
result: Vec<TelegramUpdate>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
update_id: i64,
message: Option<TelegramUpdateMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdateMessage {
from: Option<TelegramUpdateUser>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdateUser {
id: i64,
first_name: String,
username: Option<String>,
}
pub async fn setup_telegram(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<TelegramSetupResult, ChannelSetupError> {
println!("Telegram Setup:");
println!();
print_info("To create a Telegram bot:");
print_info("1. Open Telegram and message @BotFather");
print_info("2. Send /newbot and follow the prompts");
print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)");
println!();
if secrets.secret_exists("telegram_bot_token").await {
print_info("Existing Telegram token found in database.");
if !confirm("Replace existing token?", false)? {
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
return Ok(TelegramSetupResult {
enabled: true,
bot_username: None,
webhook_secret,
owner_id,
});
}
}
loop {
let token = secret_input("Bot token (from @BotFather)")?;
print_info("Validating bot token...");
match validate_telegram_token(&token).await {
Ok(username) => {
print_success(&format!(
"Bot validated: @{}",
username.as_deref().unwrap_or("unknown")
));
secrets.save_secret("telegram_bot_token", &token).await?;
print_success("Token saved to database");
let owner_id = bind_telegram_owner(&token).await?;
let webhook_secret =
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
return Ok(TelegramSetupResult {
enabled: true,
bot_username: username,
webhook_secret,
owner_id,
});
}
Err(e) => {
print_error(&format!("Token validation failed: {}", e));
if !confirm("Try again?", true)? {
return Ok(TelegramSetupResult {
enabled: false,
bot_username: None,
webhook_secret: None,
owner_id: None,
});
}
}
}
}
}
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
println!();
print_info("Account Binding (recommended):");
print_info("Binding restricts the bot so only YOU can use it.");
print_info("Without this, anyone who finds your bot can send it messages.");
println!();
if !confirm("Bind bot to your Telegram account?", true)? {
print_info("Skipping account binding. Bot will accept messages from all users.");
return Ok(None);
}
print_info("Send any message (e.g. /start) to your bot in Telegram.");
print_info("Waiting for your message (up to 120 seconds)...");
let client = Client::builder()
.timeout(std::time::Duration::from_secs(35))
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
let delete_url = format!(
"https://api.telegram.org/bot{}/deleteWebhook",
token.expose_secret()
);
if let Err(e) = client.post(&delete_url).send().await {
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
}
let updates_url = format!(
"https://api.telegram.org/bot{}/getUpdates",
token.expose_secret()
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while std::time::Instant::now() < deadline {
let response = client
.get(&updates_url)
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
.send()
.await
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
if !response.status().is_success() {
return Err(ChannelSetupError::Network(format!(
"getUpdates returned status {}",
response.status()
)));
}
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
})?;
if !body.ok {
return Err(ChannelSetupError::Network(
"Telegram API returned error for getUpdates".to_string(),
));
}
for update in &body.result {
if let Some(ref msg) = update.message
&& let Some(ref from) = msg.from
{
let display_name = from
.username
.as_ref()
.map(|u| format!("@{}", u))
.unwrap_or_else(|| from.first_name.clone());
print_success(&format!(
"Received message from {} (ID: {})",
display_name, from.id
));
let ack_url = format!(
"https://api.telegram.org/bot{}/getUpdates",
token.expose_secret()
);
if let Err(e) = client
.get(&ack_url)
.query(&[("offset", &(update.update_id + 1).to_string())])
.send()
.await
{
tracing::warn!("Failed to acknowledge Telegram update: {e}");
}
return Ok(Some(from.id));
}
}
}
print_error("Timed out waiting for a message. You can re-run setup to try again.");
print_info("Bot will accept messages from all users until owner is bound.");
Ok(None)
}
async fn bind_telegram_owner_flow(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<Option<i64>, ChannelSetupError> {
if settings.channels.telegram_owner_id.is_some() {
print_info("Bot is already bound to a Telegram account.");
if !confirm("Re-bind to a different account?", false)? {
return Ok(settings.channels.telegram_owner_id);
}
}
let token = secrets.get_secret("telegram_bot_token").await?;
bind_telegram_owner(&token).await
}
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupError> {
if let Some(ref url) = settings.tunnel.public_url {
print_info(&format!("Existing tunnel configured: {}", url));
if !confirm("Change tunnel configuration?", false)? {
return Ok(Some(url.clone()));
}
}
println!();
print_info("Tunnel Configuration (for webhook endpoints):");
print_info("A tunnel exposes your local agent to the internet, enabling:");
print_info(" - Instant Telegram message delivery (instead of polling)");
print_info(" - Future: Slack, Discord, GitHub webhooks");
print_info("");
print_info("Supported tunnel providers:");
print_info(" - ngrok: ngrok http 8080");
print_info(" - Cloudflare: cloudflared tunnel --url http://localhost:8080");
print_info(" - localtunnel: lt --port 8080");
print_info("");
print_info("Security note: Webhook endpoints don't use tunnel-level auth.");
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
println!();
if !confirm("Configure a tunnel?", false)? {
return Ok(None);
}
let tunnel_url = input("Tunnel URL (e.g., https://abc123.ngrok.io)")?;
if !tunnel_url.starts_with("https://") {
print_error("URL must start with https:// (webhooks require HTTPS)");
return Err(ChannelSetupError::Validation(
"Invalid tunnel URL: must use HTTPS".to_string(),
));
}
let tunnel_url = tunnel_url.trim_end_matches('/').to_string();
print_success(&format!("Tunnel URL configured: {}", tunnel_url));
print_info("");
print_info("Make sure your tunnel is running before starting the agent.");
print_info("You can also set TUNNEL_URL environment variable to override.");
Ok(Some(tunnel_url))
}
async fn setup_telegram_webhook_secret(
secrets: &SecretsContext,
tunnel: &TunnelSettings,
) -> Result<Option<String>, ChannelSetupError> {
if tunnel.public_url.is_none() {
print_info("");
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
print_info("Run setup again to configure a tunnel for instant delivery.");
return Ok(None);
}
println!();
print_info("Telegram Webhook Security:");
print_info("A webhook secret adds an extra layer of security by validating");
print_info("that requests actually come from Telegram's servers.");
if !confirm("Generate a webhook secret?", true)? {
return Ok(None);
}
let secret = generate_webhook_secret();
secrets
.save_secret(
"telegram_webhook_secret",
&SecretString::from(secret.clone()),
)
.await?;
print_success("Webhook secret generated and saved");
Ok(Some(secret))
}
pub async fn validate_telegram_token(
token: &SecretString,
) -> Result<Option<String>, ChannelSetupError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
let url = format!(
"https://api.telegram.org/bot{}/getMe",
token.expose_secret()
);
let response = client
.get(&url)
.send()
.await
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(ChannelSetupError::Network(format!(
"API returned status {}",
response.status()
)));
}
let body: TelegramGetMeResponse = response
.json()
.await
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
if body.ok {
Ok(body.result.and_then(|u| u.username))
} else {
Err(ChannelSetupError::Network(
"Telegram API returned error".to_string(),
))
}
}
#[derive(Debug, Clone)]
pub struct HttpSetupResult {
pub enabled: bool,
pub port: u16,
pub host: String,
}
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, ChannelSetupError> {
println!("HTTP Webhook Setup:");
println!();
print_info("The HTTP webhook allows external services to send messages to the agent.");
println!();
let port_str = optional_input("Port", Some("default: 8080"))?;
let port: u16 = port_str
.as_deref()
.unwrap_or("8080")
.parse()
.map_err(|e| ChannelSetupError::Validation(format!("Invalid port: {}", e)))?;
if port < 1024 {
print_info("Note: Ports below 1024 may require root privileges");
}
let host =
optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string());
if confirm("Generate a webhook secret for authentication?", true)? {
let secret = generate_webhook_secret();
secrets
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
Ok(HttpSetupResult {
enabled: true,
port,
host,
})
}
pub fn generate_webhook_secret() -> String {
generate_secret_with_length(32)
}
#[derive(Debug, Clone)]
pub struct WasmChannelSetupResult {
pub enabled: bool,
pub channel_name: String,
}
pub async fn setup_wasm_channel(
secrets: &SecretsContext,
channel_name: &str,
setup: &crate::channels::wasm::SetupSchema,
) -> Result<WasmChannelSetupResult, ChannelSetupError> {
println!("{} Setup:", channel_name);
println!();
for secret_config in &setup.required_secrets {
if secrets.secret_exists(&secret_config.name).await {
print_info(&format!(
"Existing {} found in database.",
secret_config.name
));
if !confirm("Replace existing value?", false)? {
continue;
}
}
let value = if secret_config.optional {
let input_value =
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))?;
if let Some(v) = input_value {
if !v.is_empty() {
SecretString::from(v)
} else if let Some(ref auto_gen) = secret_config.auto_generate {
let generated = generate_secret_with_length(auto_gen.length);
print_info(&format!(
"Auto-generated {} ({} bytes)",
secret_config.name, auto_gen.length
));
SecretString::from(generated)
} else {
continue; }
} else if let Some(ref auto_gen) = secret_config.auto_generate {
let generated = generate_secret_with_length(auto_gen.length);
print_info(&format!(
"Auto-generated {} ({} bytes)",
secret_config.name, auto_gen.length
));
SecretString::from(generated)
} else {
continue; }
} else {
let input_value = secret_input(&secret_config.prompt)?;
if let Some(ref pattern) = secret_config.validation {
let re = regex::Regex::new(pattern).map_err(|e| {
ChannelSetupError::Validation(format!("Invalid validation pattern: {}", e))
})?;
if !re.is_match(input_value.expose_secret()) {
print_error(&format!(
"Value does not match expected format: {}",
pattern
));
return Err(ChannelSetupError::Validation(
"Validation failed".to_string(),
));
}
}
input_value
};
secrets.save_secret(&secret_config.name, &value).await?;
print_success(&format!("{} saved to database", secret_config.name));
}
if let Some(ref validation_endpoint) = setup.validation_endpoint {
print_info(&format!(
"Validation endpoint configured: {} (validation not yet implemented)",
validation_endpoint
));
}
print_success(&format!("{} channel configured", channel_name));
Ok(WasmChannelSetupResult {
enabled: true,
channel_name: channel_name.to_string(),
})
}
fn generate_secret_with_length(length: usize) -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = vec![0u8; length];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
mod tests {
use crate::setup::channels::generate_webhook_secret;
#[test]
fn test_generate_webhook_secret() {
let secret = generate_webhook_secret();
assert_eq!(secret.len(), 64); }
#[test]
fn test_generate_secret_with_length() {
use super::generate_secret_with_length;
let s = generate_secret_with_length(16);
assert_eq!(s.len(), 32); assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
let s2 = generate_secret_with_length(1);
assert_eq!(s2.len(), 2);
}
}