use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use base64::Engine;
use dialoguer::{Confirm, Input, MultiSelect, Select};
use tracing::{info, warn};
struct ProviderPreset {
name: &'static str,
base_url: &'static str,
kind: &'static str,
primary: &'static str,
fast: &'static str,
smart: &'static str,
needs_key: bool,
key_url: &'static str,
key_hint: &'static str,
prompt_base_url: bool,
supports_gateway_token: bool,
}
const PRESETS: &[ProviderPreset] = &[
ProviderPreset {
name: "Google AI Studio — recommended",
base_url: "",
kind: "google_genai",
primary: "gemini-3-flash-preview",
fast: "gemini-2.5-flash-lite",
smart: "gemini-3-pro-preview",
needs_key: true,
key_url: "https://aistudio.google.com/apikey",
key_hint: "Get a free API key from Google AI Studio (free tier, includes web grounding)",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "OpenAI",
base_url: "https://api.openai.com/v1",
kind: "openai_compatible",
primary: "gpt-4o",
fast: "gpt-4o-mini",
smart: "gpt-4o",
needs_key: true,
key_url: "https://platform.openai.com/api-keys",
key_hint: "Starts with \"sk-\"",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "xAI (Grok)",
base_url: "https://api.x.ai/v1",
kind: "xai_native",
primary: "grok-4",
fast: "grok-4",
smart: "grok-4",
needs_key: true,
key_url: "https://console.x.ai",
key_hint: "Use your xAI API key (XAI_API_KEY) from xAI Console.",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "Anthropic (Native)",
base_url: "https://api.anthropic.com/v1",
kind: "anthropic",
primary: "claude-sonnet-4-20250514",
fast: "claude-haiku-4-20250414",
smart: "claude-opus-4-20250414",
needs_key: true,
key_url: "https://console.anthropic.com/settings/keys",
key_hint: "Starts with \"sk-ant-\"",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "Anthropic (via OpenRouter)",
base_url: "https://openrouter.ai/api/v1",
kind: "openai_compatible",
primary: "anthropic/claude-sonnet-4",
fast: "anthropic/claude-haiku-4",
smart: "anthropic/claude-opus-4",
needs_key: true,
key_url: "https://openrouter.ai/keys",
key_hint: "Starts with \"sk-or-\"",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "OpenRouter",
base_url: "https://openrouter.ai/api/v1",
kind: "openai_compatible",
primary: "openai/gpt-4o",
fast: "openai/gpt-4o-mini",
smart: "anthropic/claude-sonnet-4",
needs_key: true,
key_url: "https://openrouter.ai/keys",
key_hint: "Starts with \"sk-or-\". Gives access to many providers in one key.",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "DeepSeek",
base_url: "https://api.deepseek.com",
kind: "openai_compatible",
primary: "deepseek-chat",
fast: "deepseek-chat",
smart: "deepseek-reasoner",
needs_key: true,
key_url: "https://platform.deepseek.com/api_keys",
key_hint: "Starts with \"sk-\". Affordable pricing with strong reasoning models.",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "Moonshot AI (Kimi)",
base_url: "https://api.moonshot.ai/v1",
kind: "openai_compatible",
primary: "kimi-k2.5",
fast: "kimi-k2.5",
smart: "kimi-k2-thinking",
needs_key: true,
key_url: "https://platform.moonshot.ai/",
key_hint: "Moonshot OpenAI-compatible endpoint. Use your Moonshot API key.",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "MiniMax",
base_url: "https://api.minimax.io/v1",
kind: "openai_compatible",
primary: "MiniMax-M2.5",
fast: "MiniMax-M2.5-highspeed",
smart: "MiniMax-M2.5",
needs_key: true,
key_url: "https://platform.minimax.io/user-center/basic-information",
key_hint: "MiniMax OpenAI-compatible endpoint. Use your MiniMax API key.",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "Cloudflare AI Gateway",
base_url: "https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/compat",
kind: "openai_compatible",
primary: "gpt-4o-mini",
fast: "gpt-4o-mini",
smart: "gpt-4o",
needs_key: true,
key_url: "https://dash.cloudflare.com/",
key_hint: "Use your upstream LLM provider API key. You can optionally add a Cloudflare gateway token in the next step.",
prompt_base_url: true,
supports_gateway_token: true,
},
ProviderPreset {
name: "Ollama (local)",
base_url: "http://localhost:11434/v1",
kind: "openai_compatible",
primary: "llama3.1",
fast: "llama3.1",
smart: "llama3.1",
needs_key: false,
key_url: "",
key_hint: "",
prompt_base_url: false,
supports_gateway_token: false,
},
ProviderPreset {
name: "Custom (OpenAI Compatible)",
base_url: "https://api.example.com/v1",
kind: "openai_compatible",
primary: "model-name",
fast: "model-name",
smart: "model-name",
needs_key: true,
key_url: "",
key_hint: "",
prompt_base_url: true,
supports_gateway_token: false,
},
];
#[derive(Clone, Copy)]
enum ChannelSetupChoice {
Telegram,
#[cfg(feature = "discord")]
Discord,
#[cfg(feature = "slack")]
Slack,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TelegramBotTarget {
Legacy,
ArrayIndex(usize),
Dynamic(i64),
}
#[derive(Debug, Clone, Default)]
struct ExistingWebhookSettings {
enabled: bool,
public_url: Option<String>,
listen_addr: Option<String>,
path: Option<String>,
}
#[derive(Debug, Clone)]
struct TelegramBotEntry {
target: TelegramBotTarget,
display_name: String,
slug: String,
existing: ExistingWebhookSettings,
}
#[derive(Debug, Clone)]
struct TelegramWebhookPlan {
target: TelegramBotTarget,
display_name: String,
public_url: String,
listen_addr: String,
path: String,
max_connections: u8,
drop_pending_updates: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct SetupCommandSpec {
pub program: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CloudflaredIngressRoute {
pub hostname: String,
pub service: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LowLatencyEnvironment {
WranglerAndCloudflared,
WranglerOnly,
CloudflaredOnly,
Neither,
}
#[derive(Debug, Clone, Copy, Default)]
struct RuntimeProbe {
wrangler_installed: bool,
wrangler_authenticated: bool,
cloudflared_installed: bool,
cloudflared_authenticated: bool,
cloudflared_tunnel_exists: bool,
cloudflared_local_tunnel_running: Option<bool>,
}
impl RuntimeProbe {
fn environment(self) -> LowLatencyEnvironment {
match (self.wrangler_authenticated, self.cloudflared_authenticated) {
(true, true) => LowLatencyEnvironment::WranglerAndCloudflared,
(true, false) => LowLatencyEnvironment::WranglerOnly,
(false, true) => LowLatencyEnvironment::CloudflaredOnly,
(false, false) => LowLatencyEnvironment::Neither,
}
}
}
pub fn run_low_latency_setup(config_path: &Path) -> anyhow::Result<()> {
let config_raw = std::fs::read_to_string(config_path)?;
let mut doc: toml::Table = config_raw.parse()?;
let all_bots = collect_telegram_bot_entries(&doc);
if all_bots.is_empty() {
anyhow::bail!(
"No Telegram bots found in config. Configure [telegram] or [[telegram_bots]] first."
);
}
println!();
println!(" Low-Latency Telegram Setup (Opt-in)");
println!(" ------------------------------------");
println!(" Default behavior remains Telegram long polling.");
println!();
let probe = probe_runtime_environment();
print_runtime_probe_summary(probe);
let bot_labels: Vec<&str> = all_bots.iter().map(|b| b.display_name.as_str()).collect();
let defaults = vec![true; bot_labels.len()];
let selected_indices = loop {
let selected = MultiSelect::new()
.with_prompt("Select Telegram bot(s) to enable webhook low-latency mode")
.items(&bot_labels)
.defaults(&defaults)
.interact()?;
if selected.is_empty() {
println!(" Please select at least one bot.");
continue;
}
break selected;
};
let selected_bots: Vec<TelegramBotEntry> = selected_indices
.into_iter()
.map(|idx| all_bots[idx].clone())
.collect();
let hostname_mode_items = [
"Use one base domain and auto-generate per-bot hostnames (recommended)",
"Enter exact hostname per bot",
];
let hostname_mode = Select::new()
.with_prompt("How do you want to assign public HTTPS hostnames?")
.items(&hostname_mode_items)
.default(0)
.interact()?;
let mut hostnames: HashMap<TelegramBotTarget, String> = HashMap::new();
if hostname_mode == 0 {
let base_domain: String = Input::new()
.with_prompt("Base domain (example: bots.example.com)")
.validate_with(|input: &String| -> Result<(), String> {
normalize_domain_input(input)
.map(|_| ())
.map_err(|e| e.to_string())
})
.interact_text()?;
let normalized_domain = normalize_domain_input(&base_domain)?;
let mut used_labels = HashSet::new();
for bot in &selected_bots {
let label = unique_dns_label(&bot.slug, &mut used_labels);
hostnames.insert(
bot.target,
format!("{}.{}", label, normalized_domain.to_ascii_lowercase()),
);
}
} else {
for bot in &selected_bots {
let default_host = bot
.existing
.public_url
.as_deref()
.and_then(extract_hostname_from_url)
.unwrap_or_else(|| format!("{}.example.com", bot.slug));
let input: String = Input::new()
.with_prompt(format!("Hostname for {}", bot.display_name))
.default(default_host)
.validate_with(|value: &String| -> Result<(), String> {
normalize_hostname_input(value)
.map(|_| ())
.map_err(|e| e.to_string())
})
.interact_text()?;
hostnames.insert(bot.target, normalize_hostname_input(&input)?);
}
}
let bind_local_only = Confirm::new()
.with_prompt("Bind local webhook listener to 127.0.0.1 only? (recommended)")
.default(true)
.interact()?;
let listen_host = if bind_local_only {
"127.0.0.1"
} else {
"0.0.0.0"
};
let max_connections_raw: String = Input::new()
.with_prompt("Telegram max_connections (1-100)")
.default("40".to_string())
.validate_with(|value: &String| -> Result<(), String> {
parse_max_connections(value)
.map(|_| ())
.map_err(|e| e.to_string())
})
.interact_text()?;
let max_connections = parse_max_connections(&max_connections_raw)?;
let drop_pending_updates = Confirm::new()
.with_prompt("Drop pending Telegram updates when enabling webhook?")
.default(false)
.interact()?;
let keep_polling_fallback = Confirm::new()
.with_prompt("Keep automatic fallback to polling if webhook setup fails?")
.default(true)
.interact()?;
if !keep_polling_fallback {
println!(" Safety note: aidaemon currently keeps polling fallback enabled.");
println!(" This setup keeps fallback enabled to avoid regressions.");
}
let store_cf_api_token = Confirm::new()
.with_prompt("Store Cloudflare API token in keychain for future automation?")
.default(false)
.interact()?;
if store_cf_api_token {
let token: String = Input::new()
.with_prompt("Cloudflare API token")
.interact_text()?;
match crate::config::store_in_keychain("cloudflare_api_token", token.trim()) {
Ok(()) => println!(" Cloudflare API token stored in OS keychain."),
Err(err) => println!(" Warning: could not store Cloudflare API token ({err})."),
}
}
let port_usage = collect_existing_listen_port_usage(&all_bots);
let listen_ports = assign_listen_ports(&selected_bots, &port_usage)?;
let mut plans = Vec::with_capacity(selected_bots.len());
for bot in selected_bots {
let hostname = hostnames.get(&bot.target).cloned().ok_or_else(|| {
anyhow::anyhow!("missing hostname assignment for {}", bot.display_name)
})?;
let port = listen_ports
.get(&bot.target)
.copied()
.ok_or_else(|| anyhow::anyhow!("missing listen port for {}", bot.display_name))?;
let path = bot
.existing
.path
.as_deref()
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().to_string())
.unwrap_or_else(|| format!("/telegram/{}", bot.slug));
plans.push(TelegramWebhookPlan {
target: bot.target,
display_name: bot.display_name,
public_url: format!("https://{}", hostname),
listen_addr: format!("{}:{}", listen_host, port),
path,
max_connections,
drop_pending_updates,
});
}
validate_webhook_plans(&plans)?;
println!();
println!(" Planned webhook updates:");
for plan in &plans {
println!(" - {}", plan.display_name);
println!(" public_url: {}", plan.public_url);
println!(" listen_addr: {}", plan.listen_addr);
println!(" path: {}", plan.path);
}
println!();
let confirm_apply = Confirm::new()
.with_prompt("Apply these webhook settings to config.toml?")
.default(true)
.interact()?;
if !confirm_apply {
println!(" No changes were made.");
return Ok(());
}
apply_webhook_plans(&mut doc, &plans)?;
let backup_path = write_config_with_backup(config_path, &doc)?;
println!();
println!(
" Updated {} and created backup at {}",
config_path.display(),
backup_path.display()
);
println!(" Webhook mode is now enabled for selected bot(s).");
println!(" Polling fallback remains enabled for safety.");
println!();
print_low_latency_next_steps(probe.environment(), &plans);
Ok(())
}
pub fn low_latency_status_summary(config_path: &Path) -> anyhow::Result<String> {
let doc = load_config_doc(config_path)?;
let bots = collect_telegram_bot_entries(&doc);
if bots.is_empty() {
anyhow::bail!(
"No Telegram bots found in config. Configure [telegram] or [[telegram_bots]] first."
);
}
let probe = probe_runtime_environment();
let mut out = String::new();
out.push_str("Low-latency webhook status\n\n");
out.push_str(&format!(
"Runtime mode: {}\n",
runtime_mode_label(probe.environment())
));
out.push_str(&format!(
"- wrangler installed: {}\n",
yes_no(probe.wrangler_installed)
));
out.push_str(&format!(
"- wrangler authenticated: {}\n",
yes_no(probe.wrangler_authenticated)
));
out.push_str(&format!(
"- cloudflared installed: {}\n",
yes_no(probe.cloudflared_installed)
));
out.push_str(&format!(
"- cloudflared authenticated: {}\n",
yes_no(probe.cloudflared_authenticated)
));
out.push_str(&format!(
"- tunnel `aidaemon-telegram` exists: {}\n",
yes_no(probe.cloudflared_tunnel_exists)
));
out.push_str(&format!(
"- local tunnel process: {}\n",
local_tunnel_process_status(probe.cloudflared_local_tunnel_running)
));
out.push('\n');
out.push_str("Bots:\n");
for bot in bots {
let enabled = yes_no(bot.existing.enabled);
let public_url = bot.existing.public_url.as_deref().unwrap_or("n/a");
let listen_addr = bot.existing.listen_addr.as_deref().unwrap_or("n/a");
let path = bot.existing.path.as_deref().unwrap_or("n/a");
out.push_str(&format!(
"- {} | webhook enabled: {}\n public_url: {}\n listen_addr: {}\n path: {}\n",
bot.display_name, enabled, public_url, listen_addr, path
));
}
out.push('\n');
out.push_str(
"Use `/setup lowlatency plan <base-domain>` to preview and `/setup lowlatency apply <base-domain>` to apply local webhook config and prepare tunnel/DNS steps.",
);
Ok(out)
}
pub fn low_latency_plan_from_base_domain_with_dynamic(
config_path: &Path,
base_domain: &str,
dynamic_bots: &[crate::traits::DynamicBot],
) -> anyhow::Result<String> {
let doc = load_config_doc(config_path)?;
let plans =
build_low_latency_plans_for_all_bots(&doc, base_domain, 40, false, true, dynamic_bots)?;
let mut out = String::new();
out.push_str("Low-latency webhook plan (dry run)\n\n");
out.push_str(&format!(
"Runtime mode: {}\n\n",
runtime_mode_label(probe_runtime_environment().environment())
));
append_plan_lines(&mut out, &plans);
Ok(out)
}
pub fn low_latency_apply_from_base_domain_with_dynamic(
config_path: &Path,
base_domain: &str,
dynamic_bots: &[crate::traits::DynamicBot],
) -> anyhow::Result<String> {
let mut doc = load_config_doc(config_path)?;
let plans =
build_low_latency_plans_for_all_bots(&doc, base_domain, 40, false, true, dynamic_bots)?;
apply_webhook_plans(&mut doc, &plans)?;
let has_dynamic = plans
.iter()
.any(|p| matches!(p.target, TelegramBotTarget::Dynamic(_)));
if has_dynamic {
apply_telegram_webhook_defaults(&mut doc, base_domain);
}
let backup_path = write_config_with_backup(config_path, &doc)?;
let mut out = String::new();
out.push_str("Low-latency webhook config applied (local config only).\n");
out.push_str(&format!("Backup: {}\n\n", backup_path.display()));
out.push_str("Cloudflare tunnel/DNS commands are prepared below.\n\n");
append_plan_lines(&mut out, &plans);
out.push('\n');
out.push_str("Next steps:\n");
append_low_latency_next_steps(&mut out, probe_runtime_environment().environment(), &plans);
Ok(out)
}
pub fn low_latency_cloudflared_commands_from_base_domain_with_dynamic(
config_path: &Path,
base_domain: &str,
dynamic_bots: &[crate::traits::DynamicBot],
) -> anyhow::Result<Vec<SetupCommandSpec>> {
let doc = load_config_doc(config_path)?;
let plans =
build_low_latency_plans_for_all_bots(&doc, base_domain, 40, false, true, dynamic_bots)?;
Ok(low_latency_cloudflared_commands_for_plans(&plans))
}
pub fn low_latency_cloudflared_ingress_routes_from_base_domain_with_dynamic(
config_path: &Path,
base_domain: &str,
dynamic_bots: &[crate::traits::DynamicBot],
) -> anyhow::Result<Vec<CloudflaredIngressRoute>> {
let doc = load_config_doc(config_path)?;
let plans =
build_low_latency_plans_for_all_bots(&doc, base_domain, 40, false, true, dynamic_bots)?;
Ok(low_latency_cloudflared_ingress_routes_for_plans(&plans))
}
fn load_config_doc(config_path: &Path) -> anyhow::Result<toml::Table> {
let raw = std::fs::read_to_string(config_path)?;
let doc: toml::Table = raw.parse()?;
Ok(doc)
}
fn build_low_latency_plans_for_all_bots(
doc: &toml::Table,
base_domain: &str,
max_connections: u8,
drop_pending_updates: bool,
bind_local_only: bool,
dynamic_bots: &[crate::traits::DynamicBot],
) -> anyhow::Result<Vec<TelegramWebhookPlan>> {
let mut bots = collect_telegram_bot_entries(doc);
bots.extend(collect_dynamic_telegram_bot_entries(dynamic_bots));
if bots.is_empty() {
anyhow::bail!(
"No Telegram bots found (config or dynamic). Configure [telegram], [[telegram_bots]], or /connect a bot first."
);
}
let normalized_domain = normalize_domain_input(base_domain)?;
let usage = collect_existing_listen_port_usage(&bots);
let assigned = assign_listen_ports(&bots, &usage)?;
let listen_host = if bind_local_only {
"127.0.0.1"
} else {
"0.0.0.0"
};
let mut used_labels = HashSet::new();
let mut plans = Vec::with_capacity(bots.len());
for bot in bots {
let label = unique_dns_label(&bot.slug, &mut used_labels);
let port = assigned
.get(&bot.target)
.copied()
.ok_or_else(|| anyhow::anyhow!("missing listen port assignment"))?;
let path = bot
.existing
.path
.as_deref()
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().to_string())
.unwrap_or_else(|| format!("/telegram/{}", bot.slug));
let base_url = format!("https://{}.{}", label, normalized_domain);
let public_url = format!("{}{}", base_url, path);
plans.push(TelegramWebhookPlan {
target: bot.target,
display_name: bot.display_name,
public_url,
listen_addr: format!("{}:{}", listen_host, port),
path,
max_connections,
drop_pending_updates,
});
}
validate_webhook_plans(&plans)?;
Ok(plans)
}
fn low_latency_cloudflared_commands_for_plans(
plans: &[TelegramWebhookPlan],
) -> Vec<SetupCommandSpec> {
let mut out = vec![SetupCommandSpec {
program: "cloudflared".to_string(),
args: vec![
"tunnel".to_string(),
"create".to_string(),
"aidaemon-telegram".to_string(),
],
}];
let mut seen_hosts = HashSet::new();
for host in plans
.iter()
.filter_map(|plan| extract_hostname_from_url(&plan.public_url))
{
if !seen_hosts.insert(host.clone()) {
continue;
}
out.push(SetupCommandSpec {
program: "cloudflared".to_string(),
args: vec![
"tunnel".to_string(),
"route".to_string(),
"dns".to_string(),
"aidaemon-telegram".to_string(),
host,
],
});
}
out
}
fn low_latency_cloudflared_ingress_routes_for_plans(
plans: &[TelegramWebhookPlan],
) -> Vec<CloudflaredIngressRoute> {
let mut out = Vec::new();
let mut seen_hosts = HashSet::new();
for plan in plans {
let Some(hostname) = extract_hostname_from_url(&plan.public_url) else {
continue;
};
if !seen_hosts.insert(hostname.clone()) {
continue;
}
out.push(CloudflaredIngressRoute {
hostname,
service: format!("http://{}", plan.listen_addr),
});
}
out
}
fn append_plan_lines(out: &mut String, plans: &[TelegramWebhookPlan]) {
out.push_str("Planned webhook settings:\n");
for plan in plans {
out.push_str(&format!(
"- {}\n public_url: {}\n listen_addr: {}\n path: {}\n",
plan.display_name, plan.public_url, plan.listen_addr, plan.path
));
}
}
fn runtime_mode_label(environment: LowLatencyEnvironment) -> &'static str {
match environment {
LowLatencyEnvironment::WranglerAndCloudflared => "wrangler + cloudflared (near one-click)",
LowLatencyEnvironment::WranglerOnly => "wrangler only",
LowLatencyEnvironment::CloudflaredOnly => "cloudflared only",
LowLatencyEnvironment::Neither => "no cloud tooling detected",
}
}
fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
pub fn run_wizard(config_path: &Path) -> anyhow::Result<bool> {
println!();
println!(" Welcome to aidaemon!");
println!(" --------------------");
println!(" Your personal AI agent that runs 24/7 in the background.");
println!(" This wizard will get you up and running in a few steps.");
println!();
println!(" STEP 1 of 2 — Choose your AI provider");
println!(" ──────────────────────────────────────");
println!();
let provider_names: Vec<&str> = PRESETS.iter().map(|p| p.name).collect();
let selection = Select::new()
.with_prompt("Select your LLM provider")
.items(&provider_names)
.default(0)
.interact()?;
let preset = &PRESETS[selection];
let mut base_url = preset.base_url.to_string();
let mut primary = preset.primary.to_string();
let mut fast = preset.fast.to_string();
let mut smart = preset.smart.to_string();
if preset.prompt_base_url {
base_url = Input::new()
.with_prompt("Base URL (OpenAI-compatible endpoint)")
.default(base_url)
.interact_text()?;
}
let api_key = if preset.needs_key {
println!();
if !preset.key_url.is_empty() {
println!(" Get your API key here: {}", preset.key_url);
}
if !preset.key_hint.is_empty() {
println!(" Hint: {}", preset.key_hint);
}
println!();
let key: String = Input::new()
.with_prompt(format!("Paste your {} API key", preset.name))
.interact_text()?;
println!();
println!(" Checking API key...");
match validate_api_key(&key, preset.kind, &base_url) {
Ok(()) => println!(" API key is valid!"),
Err(e) => {
println!(" Warning: Could not verify API key ({})", e);
println!(" The key has been saved — you can fix it later in config.toml.");
}
}
key
} else {
"ollama".to_string()
};
let mut gateway_token: Option<String> = None;
if preset.supports_gateway_token {
println!();
println!(" Optional: enable Cloudflare Authenticated Gateway token.");
let use_gateway_token = Confirm::new()
.with_prompt("Add `cf-aig-authorization` gateway token?")
.default(false)
.interact()?;
if use_gateway_token {
let token: String = Input::new()
.with_prompt("Paste Cloudflare gateway token")
.interact_text()?;
gateway_token = Some(token);
}
}
if preset.name == "Ollama (local)" {
println!();
println!(" Checking for local Ollama models...");
match discover_ollama_models() {
Ok(models) if !models.is_empty() => {
println!(" Found {} model(s):", models.len());
let model_selection = Select::new()
.with_prompt("Select primary model")
.items(&models)
.default(0)
.interact()?;
primary = models[model_selection].clone();
fast = primary.clone();
smart = primary.clone();
}
Ok(_) => println!(" No models found. Using default: {}", primary),
Err(e) => println!(" Could not reach Ollama: {}. Using default.", e),
}
}
if preset.name == "Custom (OpenAI Compatible)" {
primary = Input::new()
.with_prompt("Primary model name")
.default(primary)
.interact_text()?;
fast = Input::new()
.with_prompt("Fast model name")
.default(fast)
.interact_text()?;
smart = Input::new()
.with_prompt("Smart model name")
.default(smart)
.interact_text()?;
}
println!();
println!(" STEP 2 of 2 — Connect channels");
println!(" ───────────────────────────────");
println!();
println!(" Select one or more channels to set up now.");
println!(" You can always add more later with /connect.");
println!();
#[cfg(not(feature = "discord"))]
println!(" TIP: Rebuild with `--features discord` to enable Discord onboarding.");
#[cfg(not(feature = "slack"))]
println!(" TIP: Rebuild with `--features slack` to enable Slack onboarding.");
#[allow(unused_mut)]
let mut channel_choices: Vec<(String, ChannelSetupChoice)> = vec![(
"Telegram (recommended baseline)".to_string(),
ChannelSetupChoice::Telegram,
)];
#[cfg(feature = "discord")]
channel_choices.push((
"Discord (bot + slash commands)".to_string(),
ChannelSetupChoice::Discord,
));
#[cfg(feature = "slack")]
channel_choices.push((
"Slack (Socket Mode + threads)".to_string(),
ChannelSetupChoice::Slack,
));
let channel_labels: Vec<&str> = channel_choices
.iter()
.map(|(label, _)| label.as_str())
.collect();
let channel_defaults: Vec<bool> = channel_choices
.iter()
.map(|(_, choice)| matches!(choice, ChannelSetupChoice::Telegram))
.collect();
let selected_indices = loop {
let selected = MultiSelect::new()
.with_prompt("Which channels do you want to configure now?")
.items(&channel_labels)
.defaults(&channel_defaults)
.interact()?;
if selected.is_empty() {
println!(" Please select at least one channel.");
continue;
}
break selected;
};
let mut selected_channel_names: Vec<String> = Vec::new();
let mut telegram_bot_token: Option<String> = None;
#[cfg(feature = "discord")]
let mut discord_bot_token: Option<String> = None;
#[cfg(feature = "discord")]
let mut discord_guild_id: Option<u64> = None;
#[cfg(feature = "slack")]
let mut slack_app_token: Option<String> = None;
#[cfg(feature = "slack")]
let mut slack_bot_token: Option<String> = None;
#[cfg(feature = "slack")]
let mut slack_use_threads: Option<bool> = None;
for idx in selected_indices {
match channel_choices[idx].1 {
ChannelSetupChoice::Telegram => {
let bot_token = prompt_telegram_setup()?;
telegram_bot_token = Some(bot_token);
selected_channel_names.push("Telegram".to_string());
}
#[cfg(feature = "discord")]
ChannelSetupChoice::Discord => {
let (bot_token, guild_id) = prompt_discord_setup()?;
discord_bot_token = Some(bot_token);
discord_guild_id = guild_id;
selected_channel_names.push("Discord".to_string());
}
#[cfg(feature = "slack")]
ChannelSetupChoice::Slack => {
let (app_token, bot_token, use_threads) = prompt_slack_setup()?;
slack_app_token = Some(app_token);
slack_bot_token = Some(bot_token);
slack_use_threads = Some(use_threads);
selected_channel_names.push("Slack".to_string());
}
}
}
#[allow(unused_mut)]
let mut browser_enabled = false;
#[allow(unused_mut)]
let mut browser_headless = true;
#[cfg(feature = "browser")]
{
println!();
println!(" Optional: Browser automation");
println!(" The browser tool lets the agent navigate websites, fill forms,");
println!(" take screenshots, and interact with authenticated services.");
println!();
let enable = Confirm::new()
.with_prompt(" Enable the browser tool?")
.default(false)
.interact()?;
if enable {
browser_enabled = true;
let mode_choices = vec![
"Visible (recommended for personal computers)",
"Headless (recommended for servers)",
];
let mode_idx = Select::new()
.with_prompt(" Browser mode")
.items(&mode_choices)
.default(0)
.interact()?;
browser_headless = mode_idx == 1;
println!();
println!(
" Browser tool enabled ({} mode).",
if browser_headless {
"headless"
} else {
"visible"
}
);
println!(" Run `aidaemon browser login` after setup to log into your services.");
}
}
let stt_probe = crate::stt_setup::probe_stt_environment();
let mut stt_enabled = false;
let mut stt_language = "en".to_string();
let mut stt_probe_final = stt_probe.clone();
println!();
println!(" Optional: Voice note transcription (Whisper STT)");
println!(" When your model lacks native audio, voice notes are transcribed");
println!(" locally via whisper-cli + ffmpeg before reaching the LLM.");
println!();
println!(
" {}",
crate::stt_setup::format_stt_probe_report(&stt_probe)
);
if stt_probe.whisper_cli.is_some() {
let enable = Confirm::new()
.with_prompt(" Enable Whisper STT fallback for voice notes?")
.default(stt_probe.is_complete())
.interact()?;
if enable {
stt_probe_final = stt_probe.clone();
if stt_probe_final.model_path.is_none() {
let model_path: String = Input::new()
.with_prompt(" Path to Whisper GGML model (.bin)")
.interact_text()?;
stt_probe_final =
crate::stt_setup::SttProbe::resolve(None, Some(model_path.trim()), None);
}
if !stt_probe_final.is_complete() {
println!(" STT stack incomplete — skipping (install ffmpeg and/or model).");
} else {
stt_enabled = true;
let lang: String = Input::new()
.with_prompt(" Transcription language (e.g. en, es; empty = auto)")
.default("en".to_string())
.allow_empty(true)
.interact_text()?;
stt_language = if lang.trim().is_empty() {
"auto".to_string()
} else {
lang.trim().to_string()
};
println!(" Whisper STT enabled.");
}
}
} else {
println!(" whisper-cli not found — install whisper.cpp to enable local transcription.");
}
let mut any_secret_in_keychain = false;
let (api_key_config, api_stored) = secret_config_value("api_key", &api_key);
if api_stored {
any_secret_in_keychain = true;
println!(" API key stored in OS keychain.");
}
let mut gateway_token_config: Option<String> = None;
if let Some(token) = gateway_token.as_deref() {
let (value, stored) = secret_config_value("gateway_token", token);
gateway_token_config = Some(value);
if stored {
any_secret_in_keychain = true;
println!(" Cloudflare gateway token stored in OS keychain.");
}
}
let mut telegram_bot_token_config: Option<String> = None;
if let Some(token) = telegram_bot_token.as_deref() {
let (value, stored) = secret_config_value("bot_token", token);
telegram_bot_token_config = Some(value);
if stored {
any_secret_in_keychain = true;
println!(" Telegram bot token stored in OS keychain.");
}
}
#[cfg(feature = "discord")]
let mut discord_bot_token_config: Option<String> = None;
#[cfg(feature = "discord")]
if let Some(token) = discord_bot_token.as_deref() {
let (value, stored) = secret_config_value("discord_bot_token", token);
discord_bot_token_config = Some(value);
if stored {
any_secret_in_keychain = true;
println!(" Discord bot token stored in OS keychain.");
}
}
#[cfg(feature = "slack")]
let mut slack_app_token_config: Option<String> = None;
#[cfg(feature = "slack")]
if let Some(token) = slack_app_token.as_deref() {
let (value, stored) = secret_config_value("slack_app_token", token);
slack_app_token_config = Some(value);
if stored {
any_secret_in_keychain = true;
println!(" Slack app token stored in OS keychain.");
}
}
#[cfg(feature = "slack")]
let mut slack_bot_token_config: Option<String> = None;
#[cfg(feature = "slack")]
if let Some(token) = slack_bot_token.as_deref() {
let (value, stored) = secret_config_value("slack_bot_token", token);
slack_bot_token_config = Some(value);
if stored {
any_secret_in_keychain = true;
println!(" Slack bot token stored in OS keychain.");
}
}
if any_secret_in_keychain {
println!();
println!(" Secrets stored in OS keychain (not in config file).");
println!(" To move to a different machine, use environment variables instead.");
}
let mut channel_sections = String::new();
if let Some(bot_token) = telegram_bot_token_config {
channel_sections.push_str("\n[telegram]\n");
channel_sections.push_str(&format!("bot_token = {bot_token}\n"));
channel_sections.push_str("allowed_user_ids = []\n");
}
#[cfg(feature = "discord")]
if let Some(bot_token) = discord_bot_token_config {
channel_sections.push_str("\n[discord]\n");
channel_sections.push_str(&format!("bot_token = {bot_token}\n"));
channel_sections.push_str("allowed_user_ids = []\n");
if let Some(guild_id) = discord_guild_id {
channel_sections.push_str(&format!("guild_id = {guild_id}\n"));
}
}
#[cfg(feature = "slack")]
if let (Some(app_token), Some(bot_token), Some(use_threads)) = (
slack_app_token_config,
slack_bot_token_config,
slack_use_threads,
) {
channel_sections.push_str("\n[slack]\n");
channel_sections.push_str(&format!("app_token = {app_token}\n"));
channel_sections.push_str(&format!("bot_token = {bot_token}\n"));
channel_sections.push_str("allowed_user_ids = []\n");
channel_sections.push_str(&format!("use_threads = {use_threads}\n"));
}
let base_url_line = if base_url.is_empty() {
String::new()
} else {
format!("base_url = \"{base_url}\"\n")
};
let gateway_token_line = gateway_token_config
.as_ref()
.map(|v| format!("gateway_token = {v}\n"))
.unwrap_or_default();
let browser_section = if browser_enabled {
format!("\n[browser]\nenabled = true\nheadless = {browser_headless}\n",)
} else {
"\n# Uncomment to enable browser tool:\n# [browser]\n# enabled = true\n".to_string()
};
let stt_section =
crate::stt_setup::stt_config_section(&stt_probe_final, stt_enabled, &stt_language);
let config = format!(
r#"[provider]
kind = "{}"
api_key = {api_key_config}
{gateway_token_line}
{base_url_line}
[provider.models]
primary = "{primary}"
fast = "{fast}"
smart = "{smart}"{channel_sections}
[state]
db_path = "aidaemon.db"
working_memory_cap = 50
[terminal]
allowed_prefixes = ["ls", "cat", "echo", "date", "whoami", "uname", "df", "du", "ps"]
web_app_url = "https://terminal.aidaemon.ai"
bridge_enabled = true
daemon_ws_url = "wss://terminal.aidaemon.ai/v1/ws/daemon"
# Optional static daemon connector token; leave unset for secure auto-bootstrap.
# daemon_connect_token = "keychain"
# Optional insecure fallback; keep disabled unless you intentionally need break-glass recovery.
# allow_static_token_fallback = false
[daemon]
health_port = 8080
{browser_section}{stt_section}
# Uncomment to enable email triggers:
# [triggers.email]
# host = "imap.gmail.com"
# port = 993
# username = "you@gmail.com"
# password = "app-password"
# folder = "INBOX"
# Uncomment to enable MCP servers:
# [mcp.filesystem]
# command = "npx"
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
"#,
preset.kind,
channel_sections = channel_sections
);
std::fs::write(config_path, &config)?;
println!();
println!(
" Setup complete! Config saved to {}",
config_path.display()
);
println!();
println!(
" You can edit {} anytime to change settings.",
config_path.display()
);
println!(" Run `aidaemon install-service` to start on boot (systemd/launchd).");
if browser_enabled {
println!(" Run `aidaemon browser login` to log into services for the agent.");
}
if stt_enabled {
println!(" Voice notes will use Whisper STT when native audio is unavailable.");
}
println!();
println!(
" Starting... Send a message on {} to get started!",
human_list(&selected_channel_names)
);
println!();
Ok(true)
}
fn probe_runtime_environment() -> RuntimeProbe {
let wrangler_installed = command_exists_sync("wrangler");
let cloudflared_installed = command_exists_sync("cloudflared");
let wrangler_authenticated =
wrangler_installed && command_succeeds_sync("wrangler", &["whoami", "--json"]);
let cloudflared_authenticated =
cloudflared_installed && command_succeeds_sync("cloudflared", &["tunnel", "list"]);
RuntimeProbe {
wrangler_installed,
wrangler_authenticated,
cloudflared_installed,
cloudflared_authenticated,
cloudflared_tunnel_exists: cloudflared_authenticated
&& command_succeeds_sync("cloudflared", &["tunnel", "info", "aidaemon-telegram"]),
cloudflared_local_tunnel_running: probe_local_cloudflared_tunnel_process(),
}
}
fn command_exists_sync(binary: &str) -> bool {
Command::new(binary).arg("--help").output().is_ok()
}
fn command_succeeds_sync(binary: &str, args: &[&str]) -> bool {
Command::new(binary)
.args(args)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn probe_local_cloudflared_tunnel_process() -> Option<bool> {
if !command_exists_sync("pgrep") {
return None;
}
let output = Command::new("pgrep")
.args(["-f", "cloudflared tunnel run aidaemon-telegram"])
.output()
.ok()?;
Some(output.status.success())
}
fn local_tunnel_process_status(value: Option<bool>) -> &'static str {
match value {
Some(true) => "running",
Some(false) => "not running",
None => "unknown (pgrep unavailable)",
}
}
fn print_runtime_probe_summary(probe: RuntimeProbe) {
println!(" Runtime probe:");
println!(
" - wrangler installed: {}",
if probe.wrangler_installed {
"yes"
} else {
"no"
}
);
println!(
" - wrangler authenticated: {}",
if probe.wrangler_authenticated {
"yes"
} else {
"no"
}
);
println!(
" - cloudflared installed: {}",
if probe.cloudflared_installed {
"yes"
} else {
"no"
}
);
println!(
" - cloudflared authenticated: {}",
if probe.cloudflared_authenticated {
"yes"
} else {
"no"
}
);
println!(
" - tunnel `aidaemon-telegram` exists: {}",
if probe.cloudflared_tunnel_exists {
"yes"
} else {
"no"
}
);
println!(
" - local tunnel process: {}",
local_tunnel_process_status(probe.cloudflared_local_tunnel_running)
);
match probe.environment() {
LowLatencyEnvironment::WranglerAndCloudflared => {
println!(" - mode: near one-click (tunnel + DNS can be configured quickly)");
}
LowLatencyEnvironment::WranglerOnly => {
println!(" - mode: wrangler-only (add cloudflared login or API token automation)");
}
LowLatencyEnvironment::CloudflaredOnly => {
println!(
" - mode: cloudflared-only (webhook can work; wrangler integration optional)"
);
}
LowLatencyEnvironment::Neither => {
println!(" - mode: no cloud tooling detected (polling remains the safe default)");
}
}
println!();
}
fn collect_telegram_bot_entries(doc: &toml::Table) -> Vec<TelegramBotEntry> {
let mut out = Vec::new();
if let Some(legacy) = doc.get("telegram").and_then(|v| v.as_table()) {
let has_token = legacy
.get("bot_token")
.and_then(|v| v.as_str())
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if has_token {
out.push(TelegramBotEntry {
target: TelegramBotTarget::Legacy,
display_name: format!(
"telegram (legacy, token: {})",
token_source_hint(legacy.get("bot_token").and_then(|v| v.as_str()))
),
slug: "legacy".to_string(),
existing: extract_existing_webhook(legacy),
});
}
}
if let Some(bots) = doc.get("telegram_bots").and_then(|v| v.as_array()) {
for (idx, bot) in bots.iter().enumerate() {
let Some(table) = bot.as_table() else {
continue;
};
let has_token = table
.get("bot_token")
.and_then(|v| v.as_str())
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if !has_token {
continue;
}
out.push(TelegramBotEntry {
target: TelegramBotTarget::ArrayIndex(idx),
display_name: format!(
"telegram_bots[{}] (token: {})",
idx,
token_source_hint(table.get("bot_token").and_then(|v| v.as_str()))
),
slug: format!("bot-{}", idx + 1),
existing: extract_existing_webhook(table),
});
}
}
out
}
fn collect_dynamic_telegram_bot_entries(
dynamic_bots: &[crate::traits::DynamicBot],
) -> Vec<TelegramBotEntry> {
dynamic_bots
.iter()
.filter(|b| b.channel_type == "telegram")
.map(|bot| {
let extra: serde_json::Value =
serde_json::from_str(&bot.extra_config).unwrap_or_default();
let username = extra["username"]
.as_str()
.filter(|s| !s.is_empty())
.unwrap_or("unknown");
let slug = username.to_string();
TelegramBotEntry {
target: TelegramBotTarget::Dynamic(bot.id),
display_name: format!("dynamic @{} (id: {})", username, bot.id),
slug,
existing: ExistingWebhookSettings::default(),
}
})
.collect()
}
fn extract_existing_webhook(bot_table: &toml::Table) -> ExistingWebhookSettings {
let Some(webhook) = bot_table.get("webhook").and_then(|v| v.as_table()) else {
return ExistingWebhookSettings::default();
};
ExistingWebhookSettings {
enabled: webhook
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
public_url: webhook
.get("public_url")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
listen_addr: webhook
.get("listen_addr")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
path: webhook
.get("path")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
}
}
fn token_source_hint(token: Option<&str>) -> &'static str {
let Some(value) = token.map(str::trim) else {
return "missing";
};
if value.is_empty() {
"missing"
} else if value == "keychain" {
"keychain"
} else if value.starts_with("${") && value.ends_with('}') {
"env"
} else {
"set"
}
}
fn normalize_domain_input(raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
if trimmed.is_empty() {
anyhow::bail!("domain cannot be empty");
}
if trimmed.contains("://") {
anyhow::bail!("domain must not include URL scheme");
}
if trimmed.contains('/') {
anyhow::bail!("domain must not include path segments");
}
if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.')
{
anyhow::bail!("domain contains invalid characters");
}
if !trimmed.contains('.') {
anyhow::bail!("domain should include at least one dot");
}
Ok(trimmed)
}
fn normalize_hostname_input(raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
if trimmed.is_empty() {
anyhow::bail!("hostname cannot be empty");
}
if trimmed.contains("://") || trimmed.contains('/') {
anyhow::bail!("hostname must be host-only, without scheme/path");
}
if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.')
{
anyhow::bail!("hostname contains invalid characters");
}
if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") {
anyhow::bail!("hostname is malformed");
}
Ok(trimmed)
}
fn unique_dns_label(base: &str, used: &mut HashSet<String>) -> String {
let mut sanitized = base
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
sanitized = "bot".to_string();
}
if sanitized.len() > 40 {
sanitized.truncate(40);
sanitized = sanitized.trim_matches('-').to_string();
}
if sanitized.is_empty() {
sanitized = "bot".to_string();
}
let mut candidate = sanitized.clone();
let mut suffix = 2usize;
while used.contains(&candidate) {
candidate = format!("{}-{}", sanitized, suffix);
suffix = suffix.saturating_add(1);
}
used.insert(candidate.clone());
candidate
}
fn parse_max_connections(raw: &str) -> anyhow::Result<u8> {
let value = raw.trim().parse::<u8>()?;
if !(1..=100).contains(&value) {
anyhow::bail!("max_connections must be in the range 1..=100");
}
Ok(value)
}
fn parse_listen_port(listen_addr: &str) -> Option<u16> {
listen_addr
.trim()
.parse::<SocketAddr>()
.ok()
.map(|addr| addr.port())
}
fn collect_existing_listen_port_usage(bots: &[TelegramBotEntry]) -> HashMap<u16, usize> {
let mut usage = HashMap::new();
for bot in bots {
let Some(addr) = bot.existing.listen_addr.as_deref() else {
continue;
};
let Some(port) = parse_listen_port(addr) else {
continue;
};
*usage.entry(port).or_insert(0) += 1;
}
usage
}
fn assign_listen_ports(
selected_bots: &[TelegramBotEntry],
all_port_usage: &HashMap<u16, usize>,
) -> anyhow::Result<HashMap<TelegramBotTarget, u16>> {
let mut occupied: HashSet<u16> = all_port_usage.keys().copied().collect();
let mut assigned: HashMap<TelegramBotTarget, u16> = HashMap::new();
for bot in selected_bots {
if let Some(existing_port) = bot
.existing
.listen_addr
.as_deref()
.and_then(parse_listen_port)
{
if all_port_usage.get(&existing_port).copied().unwrap_or(0) <= 1
&& !assigned.values().any(|&port| port == existing_port)
{
assigned.insert(bot.target, existing_port);
continue;
}
}
let mut candidate = 8443u16;
while occupied.contains(&candidate) {
candidate = candidate
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("unable to find a free listen port"))?;
}
occupied.insert(candidate);
assigned.insert(bot.target, candidate);
}
Ok(assigned)
}
fn validate_webhook_plans(plans: &[TelegramWebhookPlan]) -> anyhow::Result<()> {
let mut listen_addrs = HashSet::new();
for plan in plans {
let parsed_url = reqwest::Url::parse(&plan.public_url)?;
if parsed_url.scheme() != "https" {
anyhow::bail!(
"public_url for {} must use HTTPS (got {})",
plan.display_name,
plan.public_url
);
}
if parsed_url.host_str().is_none() {
anyhow::bail!("public_url for {} has no hostname", plan.display_name);
}
let _ = plan.listen_addr.parse::<SocketAddr>()?;
if !listen_addrs.insert(plan.listen_addr.clone()) {
anyhow::bail!("duplicate listen_addr detected: {}", plan.listen_addr);
}
if !plan.path.starts_with('/') {
anyhow::bail!("webhook path for {} must start with '/'", plan.display_name);
}
if plan.max_connections == 0 || plan.max_connections > 100 {
anyhow::bail!("max_connections for {} must be 1..=100", plan.display_name);
}
}
Ok(())
}
fn extract_hostname_from_url(raw: &str) -> Option<String> {
reqwest::Url::parse(raw)
.ok()
.and_then(|url| url.host_str().map(|host| host.to_string()))
}
fn apply_webhook_plans(doc: &mut toml::Table, plans: &[TelegramWebhookPlan]) -> anyhow::Result<()> {
for plan in plans {
match plan.target {
TelegramBotTarget::Dynamic(_) => {
continue;
}
TelegramBotTarget::Legacy => {
let webhook_value = toml::Value::Table(webhook_table_from_plan(plan));
let Some(telegram) = doc.get_mut("telegram").and_then(|v| v.as_table_mut()) else {
anyhow::bail!("Legacy [telegram] section is missing");
};
telegram.insert("webhook".to_string(), webhook_value);
}
TelegramBotTarget::ArrayIndex(idx) => {
let webhook_value = toml::Value::Table(webhook_table_from_plan(plan));
let Some(bots) = doc.get_mut("telegram_bots").and_then(|v| v.as_array_mut()) else {
anyhow::bail!("[[telegram_bots]] section is missing");
};
let Some(bot_table) = bots.get_mut(idx).and_then(|v| v.as_table_mut()) else {
anyhow::bail!("telegram_bots[{}] is missing", idx);
};
bot_table.insert("webhook".to_string(), webhook_value);
}
}
}
Ok(())
}
fn webhook_table_from_plan(plan: &TelegramWebhookPlan) -> toml::Table {
let mut webhook = toml::Table::new();
webhook.insert("enabled".to_string(), toml::Value::Boolean(true));
webhook.insert(
"public_url".to_string(),
toml::Value::String(plan.public_url.clone()),
);
webhook.insert(
"listen_addr".to_string(),
toml::Value::String(plan.listen_addr.clone()),
);
webhook.insert("path".to_string(), toml::Value::String(plan.path.clone()));
webhook.insert(
"max_connections".to_string(),
toml::Value::Integer(i64::from(plan.max_connections)),
);
webhook.insert(
"drop_pending_updates".to_string(),
toml::Value::Boolean(plan.drop_pending_updates),
);
webhook
}
fn apply_telegram_webhook_defaults(doc: &mut toml::Table, base_domain: &str) {
let mut section = toml::Table::new();
section.insert("enabled".to_string(), toml::Value::Boolean(true));
section.insert(
"base_domain".to_string(),
toml::Value::String(base_domain.to_string()),
);
section.insert("port_start".to_string(), toml::Value::Integer(8443));
section.insert("max_connections".to_string(), toml::Value::Integer(40));
section.insert(
"drop_pending_updates".to_string(),
toml::Value::Boolean(false),
);
section.insert("bind_local_only".to_string(), toml::Value::Boolean(true));
doc.insert(
"telegram_webhook_defaults".to_string(),
toml::Value::Table(section),
);
}
fn write_config_with_backup(
config_path: &Path,
doc: &toml::Table,
) -> anyhow::Result<std::path::PathBuf> {
let backup_path = config_path.with_extension("toml.lowlatency.bak");
std::fs::copy(config_path, &backup_path)?;
let rendered = toml::to_string_pretty(&toml::Value::Table(doc.clone()))?;
rendered.parse::<toml::Table>()?;
if let Err(write_err) = std::fs::write(config_path, rendered) {
let _ = std::fs::copy(&backup_path, config_path);
return Err(write_err.into());
}
match std::fs::read_to_string(config_path) {
Ok(written) => {
if let Err(parse_err) = written.parse::<toml::Table>() {
let _ = std::fs::copy(&backup_path, config_path);
return Err(anyhow::anyhow!(
"post-write config validation failed; restored backup: {}",
parse_err
));
}
}
Err(read_err) => {
let _ = std::fs::copy(&backup_path, config_path);
return Err(anyhow::anyhow!(
"failed to read written config; restored backup: {}",
read_err
));
}
}
Ok(backup_path)
}
fn print_low_latency_next_steps(environment: LowLatencyEnvironment, plans: &[TelegramWebhookPlan]) {
let mut out = String::new();
out.push_str(" Next steps:\n");
append_low_latency_next_steps(&mut out, environment, plans);
print!("{}", out);
}
fn append_low_latency_next_steps(
out: &mut String,
environment: LowLatencyEnvironment,
plans: &[TelegramWebhookPlan],
) {
match environment {
LowLatencyEnvironment::WranglerAndCloudflared | LowLatencyEnvironment::CloudflaredOnly => {
out.push_str("1. Create or reuse a named tunnel:\n");
out.push_str(" cloudflared tunnel create aidaemon-telegram\n");
out.push_str("2. Add DNS routes for your webhook hosts:\n");
for host in plans
.iter()
.filter_map(|plan| extract_hostname_from_url(&plan.public_url))
{
out.push_str(&format!(
" cloudflared tunnel route dns aidaemon-telegram {}\n",
host
));
}
out.push_str(
"3. Run your tunnel and route traffic to the configured local listen_addr values.\n",
);
}
LowLatencyEnvironment::WranglerOnly => {
out.push_str("1. Install/login cloudflared (`cloudflared tunnel login`) or\n");
out.push_str("2. Use Cloudflare API token automation when available.\n");
out.push_str("3. Until then, polling fallback keeps bots working.\n");
}
LowLatencyEnvironment::Neither => {
out.push_str("1. Keep polling as default, or\n");
out.push_str("2. Install cloudflared + authenticate to enable webhook delivery.\n");
}
}
}
fn prompt_telegram_setup() -> anyhow::Result<String> {
println!();
println!(" Telegram setup");
println!(" 1. Open Telegram and search for @BotFather");
println!(" 2. Send /newbot and follow the prompts");
println!(" 3. Copy the bot token (looks like 123456:ABC-DEF...)");
println!();
let bot_token: String = Input::new()
.with_prompt("Telegram bot token")
.interact_text()?;
println!();
println!(" Checking Telegram token...");
match validate_bot_token(&bot_token) {
Ok(bot_name) => {
println!(" Connected to Telegram bot @{}", bot_name);
println!();
println!(" Send a DM to @{} to get started!", bot_name);
}
Err(e) => {
println!(" Warning: Could not verify Telegram token ({})", e);
println!(" The token has been saved — you can fix it later in config.toml.");
}
}
Ok(bot_token)
}
#[cfg(feature = "discord")]
fn prompt_discord_setup() -> anyhow::Result<(String, Option<u64>)> {
println!();
println!(" Discord setup");
println!(" 1. Create an app at https://discord.com/developers/applications");
println!(" 2. Add a bot user and copy its token");
println!(" 3. Invite the bot to your server (or use DMs)");
println!();
let bot_token: String = Input::new()
.with_prompt("Discord bot token")
.interact_text()?;
println!();
println!(" Checking Discord token...");
match validate_discord_bot_token(&bot_token) {
Ok(bot_name) => {
println!(" Connected to Discord bot {}", bot_name);
println!();
println!(" Send a DM to {} to get started!", bot_name);
}
Err(e) => {
println!(" Warning: Could not verify Discord token ({})", e);
println!(" The token has been saved — you can fix it later in config.toml.");
}
}
let guild_input: String = Input::new()
.with_prompt("Optional Discord server (guild) ID (leave blank for any)")
.allow_empty(true)
.validate_with(|input: &String| -> Result<(), &str> {
if input.trim().is_empty() || input.trim().parse::<u64>().is_ok() {
Ok(())
} else {
Err("Please enter a numeric guild ID or leave blank")
}
})
.interact_text()?;
let guild_id = if guild_input.trim().is_empty() {
None
} else {
Some(guild_input.trim().parse().expect("already validated"))
};
Ok((bot_token, guild_id))
}
#[cfg(feature = "slack")]
fn prompt_slack_setup() -> anyhow::Result<(String, String, bool)> {
println!();
println!(" Slack setup");
println!(" 1. Create a Slack app at https://api.slack.com/apps");
println!(" 2. Enable Socket Mode and copy the app token (xapp-...)");
println!(" 3. Install the app and copy the bot token (xoxb-...)");
println!();
let bot_token: String = Input::new()
.with_prompt("Slack bot token (xoxb-...)")
.interact_text()?;
let app_token: String = Input::new()
.with_prompt("Slack app token (xapp-...)")
.interact_text()?;
println!();
println!(" Checking Slack bot token...");
match validate_slack_bot_token(&bot_token) {
Ok((bot_name, team_name)) => {
println!(" Connected to Slack bot {} in {}", bot_name, team_name);
println!();
println!(
" Send a DM to @{} in {} to get started!",
bot_name, team_name
);
}
Err(e) => {
println!(" Warning: Could not verify Slack bot token ({})", e);
println!(" The token has been saved — you can fix it later in config.toml.");
}
}
println!(" Checking Slack app token...");
match validate_slack_app_token(&app_token) {
Ok(()) => println!(" Slack app token is valid."),
Err(e) => {
println!(" Warning: Could not verify Slack app token ({})", e);
println!(" The token has been saved — you can fix it later in config.toml.");
}
}
let use_threads: bool = {
use dialoguer::Confirm;
Confirm::new()
.with_prompt("Reply in Slack threads?")
.default(true)
.interact()?
};
Ok((app_token, bot_token, use_threads))
}
fn quote_toml_string(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn secret_config_value(keychain_key: &str, secret: &str) -> (String, bool) {
match crate::config::store_in_keychain(keychain_key, secret) {
Ok(()) => ("\"keychain\"".to_string(), true),
Err(e) => {
tracing::debug!("Keychain unavailable for {}: {}", keychain_key, e);
(quote_toml_string(secret), false)
}
}
}
fn human_list(items: &[String]) -> String {
match items {
[] => "your configured channel".to_string(),
[one] => one.clone(),
[first, second] => format!("{first} and {second}"),
_ => {
let head = &items[..items.len() - 1];
let last = &items[items.len() - 1];
format!("{}, and {}", head.join(", "), last)
}
}
}
fn is_cloudflare_ai_gateway_base(base_url: &str) -> bool {
let parsed = match reqwest::Url::parse(base_url) {
Ok(url) => url,
Err(_) => return false,
};
matches!(
parsed.host_str(),
Some(host) if host.eq_ignore_ascii_case("gateway.ai.cloudflare.com")
)
}
fn cloudflare_models_probe_url(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
if trimmed.ends_with("/compat") {
format!("{trimmed}/v1/models")
} else {
format!("{trimmed}/compat/v1/models")
}
}
fn validate_api_key(key: &str, kind: &str, base_url: &str) -> anyhow::Result<()> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
match kind {
"anthropic" => {
let resp = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.body(r#"{"model":"claude-haiku-4-20250414","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}"#)
.send()?;
let status = resp.status().as_u16();
if status == 401 || status == 403 {
anyhow::bail!("invalid API key");
}
Ok(())
}
"google_genai" => {
let url = "https://generativelanguage.googleapis.com/v1beta/models";
let resp = client.get(url).header("x-goog-api-key", key).send()?;
let status = resp.status().as_u16();
if status == 400 || status == 401 || status == 403 {
anyhow::bail!("invalid API key");
}
Ok(())
}
_ => {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let status = client
.get(&url)
.header("Authorization", format!("Bearer {}", key))
.send()?
.status()
.as_u16();
if status == 401 || status == 403 {
anyhow::bail!("invalid API key");
}
if is_cloudflare_ai_gateway_base(base_url) && (status == 404 || status == 405) {
let cf_url = cloudflare_models_probe_url(base_url);
let cf_status = client
.get(&cf_url)
.header("Authorization", format!("Bearer {}", key))
.send()?
.status()
.as_u16();
if cf_status == 401 || cf_status == 403 {
anyhow::bail!("invalid API key");
}
if cf_status == 404 || cf_status == 405 {
anyhow::bail!(
"could not verify API key: models endpoint unavailable at '{}' and '{}'",
url,
cf_url
);
}
}
Ok(())
}
}
}
fn validate_bot_token(token: &str) -> anyhow::Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let url = format!("https://api.telegram.org/bot{}/getMe", token);
let resp = client.get(&url).send()?;
let data: serde_json::Value = resp.json()?;
if data["ok"].as_bool() != Some(true) {
anyhow::bail!(
"{}",
data["description"].as_str().unwrap_or("invalid token")
);
}
let username = data["result"]["username"]
.as_str()
.unwrap_or("unknown")
.to_string();
Ok(username)
}
#[cfg(feature = "discord")]
fn validate_discord_bot_token(token: &str) -> anyhow::Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", token))
.send()?;
let status = resp.status();
let data: serde_json::Value = resp.json().unwrap_or_default();
if !status.is_success() {
let message = data["message"].as_str().unwrap_or("invalid token");
anyhow::bail!("{} (HTTP {})", message, status.as_u16());
}
let username = data["username"].as_str().unwrap_or("unknown").to_string();
Ok(username)
}
#[cfg(feature = "slack")]
fn validate_slack_bot_token(token: &str) -> anyhow::Result<(String, String)> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client
.get("https://slack.com/api/auth.test")
.header("Authorization", format!("Bearer {}", token))
.send()?;
let data: serde_json::Value = resp.json()?;
if data["ok"].as_bool() != Some(true) {
anyhow::bail!(
"{}",
data["error"].as_str().unwrap_or("invalid Slack bot token")
);
}
let user = data["user"].as_str().unwrap_or("unknown").to_string();
let team = data["team"].as_str().unwrap_or("unknown").to_string();
Ok((user, team))
}
#[cfg(feature = "slack")]
fn validate_slack_app_token(token: &str) -> anyhow::Result<()> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client
.post("https://slack.com/api/apps.connections.open")
.header("Authorization", format!("Bearer {}", token))
.send()?;
let data: serde_json::Value = resp.json()?;
if data["ok"].as_bool() != Some(true) {
anyhow::bail!(
"{}",
data["error"].as_str().unwrap_or("invalid Slack app token")
);
}
Ok(())
}
fn discover_ollama_models() -> anyhow::Result<Vec<String>> {
let resp = reqwest::blocking::get("http://localhost:11434/api/tags")?;
let data: serde_json::Value = resp.json()?;
let models: Vec<String> = data["models"]
.as_array()
.map(|arr: &Vec<serde_json::Value>| {
arr.iter()
.filter_map(|m: &serde_json::Value| m["name"].as_str().map(|s: &str| s.to_string()))
.collect::<Vec<String>>()
})
.unwrap_or_default();
Ok(models)
}
#[derive(Debug)]
pub(crate) enum CloudflaredZoneValidation {
Match {
zone_name: String,
},
Mismatch {
zone_name: String,
mismatched_hosts: Vec<String>,
},
Unknown {
reason: String,
},
}
pub(crate) fn hostname_matches_zone(host: &str, zone: &str) -> bool {
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
let zone = zone.trim().trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() || zone.is_empty() {
return false;
}
host == zone || host.ends_with(&format!(".{}", zone))
}
pub(crate) fn cloudflared_origin_cert_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("TUNNEL_ORIGIN_CERT") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".cloudflared").join("cert.pem"))
}
pub(crate) fn cloudflared_reports_existing_resource(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
lower.contains("already exists")
|| lower.contains("already routed")
|| lower.contains("already points to")
|| lower.contains("record already exists")
}
pub(crate) fn format_setup_command(spec: &SetupCommandSpec) -> String {
format!("{} {}", spec.program, spec.args.join(" "))
}
pub(crate) fn installer_for_missing_tool(
tool: &str,
has_brew: bool,
has_npm: bool,
) -> Option<SetupCommandSpec> {
match tool {
"cloudflared" if has_brew => Some(SetupCommandSpec {
program: "brew".to_string(),
args: vec!["install".to_string(), "cloudflared".to_string()],
}),
"wrangler" if has_npm => Some(SetupCommandSpec {
program: "npm".to_string(),
args: vec![
"install".to_string(),
"-g".to_string(),
"wrangler".to_string(),
],
}),
"wrangler" if has_brew => Some(SetupCommandSpec {
program: "brew".to_string(),
args: vec!["install".to_string(), "wrangler".to_string()],
}),
_ => None,
}
}
pub(crate) fn summarize_setup_command_output(stdout: &[u8], stderr: &[u8]) -> String {
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(stdout),
String::from_utf8_lossy(stderr)
);
let mut pieces = Vec::new();
for line in combined.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
pieces.push(trimmed.to_string());
if pieces.len() >= 2 {
break;
}
}
pieces.join(" | ")
}
pub(crate) fn setup_route_dns_hosts(specs: &[SetupCommandSpec]) -> Vec<String> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for spec in specs {
if !spec.program.eq_ignore_ascii_case("cloudflared") {
continue;
}
if spec.args.len() < 5 {
continue;
}
if spec.args[0] != "tunnel" || spec.args[1] != "route" || spec.args[2] != "dns" {
continue;
}
let host = spec.args[4]
.trim()
.trim_end_matches('.')
.to_ascii_lowercase();
if host.is_empty() {
continue;
}
if seen.insert(host.clone()) {
out.push(host);
}
}
out
}
pub(crate) fn extract_urls(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for raw in text.split_whitespace() {
let candidate = raw
.trim_matches(|c: char| {
matches!(
c,
'"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';'
)
})
.trim();
if (candidate.starts_with("https://") || candidate.starts_with("http://"))
&& seen.insert(candidate.to_string())
{
out.push(candidate.to_string());
}
}
out
}
pub(crate) fn summarize_setup_log_lines(lines: &[String]) -> String {
let mut out = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
out.push(trimmed.to_string());
if out.len() >= 2 {
break;
}
}
out.join(" | ")
}
pub(crate) fn render_cloudflared_ingress_config(routes: &[CloudflaredIngressRoute]) -> String {
let mut out = String::from("# Managed by aidaemon /setup lowlatency apply\ningress:\n");
for route in routes {
out.push_str(&format!(
" - hostname: {}\n service: {}\n",
route.hostname, route.service
));
}
out.push_str(" - service: http_status:404\n");
out
}
pub(crate) async fn command_exists(binary: &str) -> bool {
tokio::time::timeout(
Duration::from_secs(5),
tokio::process::Command::new(binary)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.output(),
)
.await
.map(|res| res.is_ok())
.unwrap_or(false)
}
pub(crate) async fn command_succeeds(binary: &str, args: &[&str], timeout_secs: u64) -> bool {
tokio::time::timeout(
Duration::from_secs(timeout_secs),
tokio::process::Command::new(binary)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.output(),
)
.await
.map(|res| res.map(|o| o.status.success()).unwrap_or(false))
.unwrap_or(false)
}
pub(crate) async fn cloudflared_authenticated_zone_name() -> Result<Option<String>, String> {
let Some(cert_path) = cloudflared_origin_cert_path() else {
return Ok(None);
};
let cert_content = match tokio::fs::read_to_string(&cert_path).await {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(format!(
"failed to read cloudflared origin cert at `{}`: {}",
cert_path.display(),
err
))
}
};
let payload = cert_content
.lines()
.map(str::trim)
.filter(|line| !line.starts_with("-----"))
.collect::<String>();
if payload.is_empty() {
return Ok(None);
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(payload.as_bytes())
.map_err(|err| format!("failed to decode cloudflared cert payload: {}", err))?;
let parsed: serde_json::Value = serde_json::from_slice(&decoded)
.map_err(|err| format!("failed to parse cloudflared cert payload: {}", err))?;
let zone_id = parsed
.get("zoneID")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or_default();
let api_token = parsed
.get("apiToken")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or_default();
if zone_id.is_empty() || api_token.is_empty() {
return Ok(None);
}
let url = format!("https://api.cloudflare.com/client/v4/zones/{}", zone_id);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| format!("failed to build Cloudflare HTTP client: {}", err))?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_token))
.header("Content-Type", "application/json")
.send()
.await
.map_err(|err| format!("Cloudflare zone lookup request failed: {}", err))?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.map_err(|err| format!("failed to parse Cloudflare zone lookup response: {}", err))?;
if !status.is_success() {
let message = body
.get("errors")
.and_then(|errors| errors.as_array())
.and_then(|errors| errors.first())
.and_then(|err| err.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(format!(
"Cloudflare zone lookup failed (HTTP {}): {}",
status, message
));
}
let zone_name = body
.get("result")
.and_then(|result| result.get("name"))
.and_then(|v| v.as_str())
.map(|name| name.trim().trim_end_matches('.').to_ascii_lowercase())
.filter(|name| !name.is_empty());
Ok(zone_name)
}
pub(crate) async fn validate_cloudflared_zone_for_hosts(
hosts: &[String],
) -> CloudflaredZoneValidation {
if hosts.is_empty() {
return CloudflaredZoneValidation::Unknown {
reason: "no DNS hosts were generated for this setup".to_string(),
};
}
match cloudflared_authenticated_zone_name().await {
Ok(Some(zone_name)) => {
let mismatched_hosts = hosts
.iter()
.filter(|host| !hostname_matches_zone(host, &zone_name))
.cloned()
.collect::<Vec<_>>();
if mismatched_hosts.is_empty() {
CloudflaredZoneValidation::Match { zone_name }
} else {
CloudflaredZoneValidation::Mismatch {
zone_name,
mismatched_hosts,
}
}
}
Ok(None) => CloudflaredZoneValidation::Unknown {
reason: "could not determine cloudflared zone from local credentials".to_string(),
},
Err(reason) => CloudflaredZoneValidation::Unknown { reason },
}
}
fn managed_cloudflared_config_path(tunnel_name: &str) -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed)
.join(".cloudflared")
.join(format!("{}.aidaemon-ingress.yml", tunnel_name));
}
}
PathBuf::from(format!("{}.aidaemon-ingress.yml", tunnel_name))
}
pub(crate) async fn write_cloudflared_ingress_config(
tunnel_name: &str,
routes: &[CloudflaredIngressRoute],
) -> Result<PathBuf, String> {
if routes.is_empty() {
return Err("no ingress routes were generated for cloudflared".to_string());
}
let config_path = managed_cloudflared_config_path(tunnel_name);
if let Some(parent) = config_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
format!(
"failed to create cloudflared config directory `{}`: {}",
parent.display(),
err
)
})?;
}
let rendered = render_cloudflared_ingress_config(routes);
tokio::fs::write(&config_path, rendered)
.await
.map_err(|err| {
format!(
"failed to write cloudflared ingress config `{}`: {}",
config_path.display(),
err
)
})?;
Ok(config_path)
}
async fn running_cloudflared_tunnel_pids(tunnel_name: &str) -> Result<Vec<u32>, String> {
let pattern = format!("cloudflared.*tunnel.*run.*{}", tunnel_name);
let output = match tokio::process::Command::new("pgrep")
.args(["-f", &pattern])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
{
Ok(output) => output,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(format!(
"failed to inspect existing cloudflared process: {}",
err
))
}
};
if !output.status.success() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match trimmed.parse::<u32>() {
Ok(pid) if seen.insert(pid) => out.push(pid),
_ => continue,
}
}
Ok(out)
}
#[cfg(unix)]
async fn is_pid_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
tokio::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
async fn is_pid_alive(_pid: u32) -> bool {
false
}
async fn stop_cloudflared_tunnel_processes(pids: &[u32]) -> Vec<u32> {
if pids.is_empty() {
return Vec::new();
}
#[cfg(unix)]
{
for pid in pids {
let _ = tokio::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
tokio::time::sleep(Duration::from_secs(1)).await;
for pid in pids {
if is_pid_alive(*pid).await {
let _ = tokio::process::Command::new("kill")
.args(["-KILL", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
}
tokio::time::sleep(Duration::from_millis(200)).await;
let mut stopped = Vec::new();
for pid in pids {
if !is_pid_alive(*pid).await {
stopped.push(*pid);
}
}
stopped
}
#[cfg(not(unix))]
{
let _ = pids;
Vec::new()
}
}
pub(crate) async fn stop_existing_cloudflared_tunnel_processes(
tunnel_name: &str,
) -> Result<Vec<u32>, String> {
let running = running_cloudflared_tunnel_pids(tunnel_name).await?;
if running.is_empty() {
return Ok(Vec::new());
}
let stopped = stop_cloudflared_tunnel_processes(&running).await;
if stopped.len() < running.len() {
let stopped_set: HashSet<u32> = stopped.iter().copied().collect();
let stuck: Vec<u32> = running
.iter()
.copied()
.filter(|pid| !stopped_set.contains(pid))
.collect();
return Err(format!(
"failed to stop existing tunnel process(es): {}",
stuck
.iter()
.map(|pid| pid.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
Ok(stopped)
}
pub(crate) async fn start_cloudflared_tunnel_background(
tunnel_name: &str,
routes: &[CloudflaredIngressRoute],
) -> Result<String, String> {
let config_path = write_cloudflared_ingress_config(tunnel_name, routes).await?;
let stopped = stop_existing_cloudflared_tunnel_processes(tunnel_name).await?;
let mut child = tokio::process::Command::new("cloudflared")
.arg("tunnel")
.arg("--config")
.arg(&config_path)
.arg("run")
.arg(tunnel_name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|err| {
format!(
"failed to spawn `cloudflared tunnel --config {} run {}`: {}",
config_path.display(),
tunnel_name,
err
)
})?;
let pid = child.id();
tokio::time::sleep(Duration::from_secs(2)).await;
match child.try_wait() {
Ok(Some(status)) => Err(format!(
"`cloudflared tunnel --config {} run {}` exited immediately with status {}",
config_path.display(),
tunnel_name,
status
)),
Ok(None) => {
let tunnel = tunnel_name.to_string();
tokio::spawn(async move {
match child.wait().await {
Ok(status) if status.success() => {
info!(tunnel = %tunnel, "cloudflared tunnel process exited");
}
Ok(status) => {
warn!(
tunnel = %tunnel,
status = %status,
"cloudflared tunnel process exited unexpectedly"
);
}
Err(err) => {
warn!(
tunnel = %tunnel,
error = %err,
"failed while waiting for cloudflared tunnel process"
);
}
}
});
let mut pieces = Vec::new();
if !stopped.is_empty() {
pieces.push(format!(
"Stopped existing tunnel process(es): {}.",
stopped
.iter()
.map(|pid| pid.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
if let Some(pid) = pid {
pieces.push(format!(
"Wrote ingress config to `{}`.",
config_path.display()
));
pieces.push(format!(
"Started `cloudflared tunnel --config {} run {}` in background (pid {}).",
config_path.display(),
tunnel_name,
pid
));
} else {
pieces.push(format!(
"Wrote ingress config to `{}`.",
config_path.display()
));
pieces.push(format!(
"Started `cloudflared tunnel --config {} run {}` in background.",
config_path.display(),
tunnel_name
));
}
Ok(pieces.join(" "))
}
Err(err) => Err(format!(
"failed to check cloudflared process status: {}",
err
)),
}
}
#[cfg(test)]
mod tests {
use super::{
apply_webhook_plans, assign_listen_ports, build_low_latency_plans_for_all_bots,
cloudflare_models_probe_url, collect_telegram_bot_entries, hostname_matches_zone,
is_cloudflare_ai_gateway_base, low_latency_cloudflared_ingress_routes_for_plans,
normalize_domain_input, normalize_hostname_input, render_cloudflared_ingress_config,
setup_route_dns_hosts, CloudflaredIngressRoute, SetupCommandSpec, TelegramBotTarget,
TelegramWebhookPlan,
};
#[test]
fn cloudflare_gateway_host_detection() {
assert!(is_cloudflare_ai_gateway_base(
"https://gateway.ai.cloudflare.com/v1/account/gateway/compat"
));
assert!(!is_cloudflare_ai_gateway_base("https://api.openai.com/v1"));
}
#[test]
fn cloudflare_models_probe_url_with_compat_suffix() {
assert_eq!(
cloudflare_models_probe_url(
"https://gateway.ai.cloudflare.com/v1/account/gateway/compat"
),
"https://gateway.ai.cloudflare.com/v1/account/gateway/compat/v1/models"
);
}
#[test]
fn cloudflare_models_probe_url_without_compat_suffix() {
assert_eq!(
cloudflare_models_probe_url("https://gateway.ai.cloudflare.com/v1/account/gateway"),
"https://gateway.ai.cloudflare.com/v1/account/gateway/compat/v1/models"
);
}
#[test]
fn normalize_domain_input_rejects_url_scheme() {
assert!(normalize_domain_input("https://example.com").is_err());
assert!(normalize_domain_input("example.com").is_ok());
}
#[test]
fn normalize_hostname_input_rejects_path() {
assert!(normalize_hostname_input("bot.example.com/path").is_err());
assert!(normalize_hostname_input("bot.example.com").is_ok());
}
#[test]
fn assign_listen_ports_preserves_unique_existing_and_fixes_conflicts() {
let doc: toml::Table = r#"
[telegram]
bot_token = "legacy-token"
[telegram.webhook]
listen_addr = "127.0.0.1:8443"
[[telegram_bots]]
bot_token = "bot-1"
[telegram_bots.webhook]
listen_addr = "127.0.0.1:8443"
[[telegram_bots]]
bot_token = "bot-2"
[telegram_bots.webhook]
listen_addr = "127.0.0.1:8445"
"#
.parse()
.expect("parse toml");
let bots = collect_telegram_bot_entries(&doc);
let selected = vec![bots[0].clone(), bots[1].clone(), bots[2].clone()];
let usage = super::collect_existing_listen_port_usage(&bots);
let assigned = assign_listen_ports(&selected, &usage).expect("assign ports");
let legacy = assigned[&TelegramBotTarget::Legacy];
let bot0 = assigned[&TelegramBotTarget::ArrayIndex(0)];
let bot1 = assigned[&TelegramBotTarget::ArrayIndex(1)];
assert_ne!(legacy, bot0);
assert_eq!(bot1, 8445);
}
#[test]
fn apply_webhook_plans_updates_legacy_and_array_entries() {
let mut doc: toml::Table = r#"
[telegram]
bot_token = "legacy-token"
[[telegram_bots]]
bot_token = "bot-1"
"#
.parse()
.expect("parse toml");
let plans = vec![
TelegramWebhookPlan {
target: TelegramBotTarget::Legacy,
display_name: "legacy".to_string(),
public_url: "https://legacy.example.com".to_string(),
listen_addr: "127.0.0.1:8443".to_string(),
path: "/telegram/legacy".to_string(),
max_connections: 40,
drop_pending_updates: false,
},
TelegramWebhookPlan {
target: TelegramBotTarget::ArrayIndex(0),
display_name: "bot-1".to_string(),
public_url: "https://bot1.example.com".to_string(),
listen_addr: "127.0.0.1:8444".to_string(),
path: "/telegram/bot-1".to_string(),
max_connections: 40,
drop_pending_updates: true,
},
];
apply_webhook_plans(&mut doc, &plans).expect("apply plans");
let legacy_webhook = doc
.get("telegram")
.and_then(|v| v.as_table())
.and_then(|v| v.get("webhook"))
.and_then(|v| v.as_table())
.expect("legacy webhook");
assert_eq!(
legacy_webhook
.get("public_url")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"https://legacy.example.com"
);
let array_webhook = doc
.get("telegram_bots")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_table())
.and_then(|v| v.get("webhook"))
.and_then(|v| v.as_table())
.expect("array webhook");
assert_eq!(
array_webhook
.get("listen_addr")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"127.0.0.1:8444"
);
}
#[test]
fn build_low_latency_plans_generates_https_hosts_and_unique_ports() {
let doc: toml::Table = r#"
[telegram]
bot_token = "legacy-token"
[telegram.webhook]
listen_addr = "127.0.0.1:8443"
path = "/legacy"
[[telegram_bots]]
bot_token = "bot-1"
[telegram_bots.webhook]
listen_addr = "127.0.0.1:8443"
[[telegram_bots]]
bot_token = "bot-2"
"#
.parse()
.expect("parse toml");
let plans =
build_low_latency_plans_for_all_bots(&doc, "bots.example.com", 40, false, true, &[])
.expect("build plans");
assert_eq!(plans.len(), 3);
let mut listen_addrs = std::collections::HashSet::new();
for plan in &plans {
assert!(plan.public_url.starts_with("https://"));
assert!(listen_addrs.insert(plan.listen_addr.clone()));
}
}
#[test]
fn ingress_routes_derive_hostname_and_service_from_plans() {
let plans = vec![
TelegramWebhookPlan {
target: TelegramBotTarget::Legacy,
display_name: "legacy".to_string(),
public_url: "https://legacy.example.com".to_string(),
listen_addr: "127.0.0.1:8443".to_string(),
path: "/telegram/legacy".to_string(),
max_connections: 40,
drop_pending_updates: false,
},
TelegramWebhookPlan {
target: TelegramBotTarget::ArrayIndex(0),
display_name: "bot-1".to_string(),
public_url: "https://bot1.example.com".to_string(),
listen_addr: "127.0.0.1:8444".to_string(),
path: "/telegram/bot-1".to_string(),
max_connections: 40,
drop_pending_updates: false,
},
TelegramWebhookPlan {
target: TelegramBotTarget::ArrayIndex(1),
display_name: "duplicate-host".to_string(),
public_url: "https://legacy.example.com".to_string(),
listen_addr: "127.0.0.1:8555".to_string(),
path: "/telegram/dup".to_string(),
max_connections: 40,
drop_pending_updates: false,
},
];
let routes = low_latency_cloudflared_ingress_routes_for_plans(&plans);
assert_eq!(
routes,
vec![
CloudflaredIngressRoute {
hostname: "legacy.example.com".to_string(),
service: "http://127.0.0.1:8443".to_string(),
},
CloudflaredIngressRoute {
hostname: "bot1.example.com".to_string(),
service: "http://127.0.0.1:8444".to_string(),
},
]
);
}
#[test]
fn hostname_matches_zone_checks_suffix_boundaries() {
assert!(hostname_matches_zone("legacy.aidaemon.ai", "aidaemon.ai"));
assert!(hostname_matches_zone("aidaemon.ai", "aidaemon.ai"));
assert!(!hostname_matches_zone(
"legacy.aidaemon.ai",
"davidloor.com"
));
}
#[test]
fn setup_route_dns_hosts_extracts_unique_cloudflared_hosts() {
let specs = vec![
SetupCommandSpec {
program: "cloudflared".to_string(),
args: vec![
"tunnel".to_string(),
"create".to_string(),
"aidaemon-telegram".to_string(),
],
},
SetupCommandSpec {
program: "cloudflared".to_string(),
args: vec![
"tunnel".to_string(),
"route".to_string(),
"dns".to_string(),
"aidaemon-telegram".to_string(),
"legacy.aidaemon.ai".to_string(),
],
},
SetupCommandSpec {
program: "cloudflared".to_string(),
args: vec![
"tunnel".to_string(),
"route".to_string(),
"dns".to_string(),
"aidaemon-telegram".to_string(),
"legacy.aidaemon.ai".to_string(),
],
},
SetupCommandSpec {
program: "echo".to_string(),
args: vec!["ignore".to_string()],
},
];
let hosts = setup_route_dns_hosts(&specs);
assert_eq!(hosts, vec!["legacy.aidaemon.ai"]);
}
#[test]
fn render_cloudflared_ingress_config_contains_routes_and_fallback() {
let routes = vec![
CloudflaredIngressRoute {
hostname: "legacy.aidaemon.ai".to_string(),
service: "http://127.0.0.1:8443".to_string(),
},
CloudflaredIngressRoute {
hostname: "bot1.aidaemon.ai".to_string(),
service: "http://127.0.0.1:8444".to_string(),
},
];
let rendered = render_cloudflared_ingress_config(&routes);
assert!(rendered.contains("ingress:\n"));
assert!(rendered.contains("hostname: legacy.aidaemon.ai"));
assert!(rendered.contains("service: http://127.0.0.1:8443"));
assert!(rendered.contains("hostname: bot1.aidaemon.ai"));
assert!(rendered.contains("service: http://127.0.0.1:8444"));
assert!(rendered.contains("service: http_status:404"));
}
}