Onboarding: select bundled Telegram channel and auto-install (#3)

Co-authored-by: Firat Sertgoz <[email protected]>
This commit is contained in:
firat.sertgoz
2026-02-07 06:56:01 +00:00
committed by GitHub
co-authored by Firat Sertgoz
parent 6bcc168ec5
commit 6831a54793
6 changed files with 314 additions and 40 deletions
+104
View File
@@ -0,0 +1,104 @@
//! Bundled WASM channels that can be installed locally.
use std::path::Path;
use tokio::fs;
#[derive(Clone, Copy)]
struct BundledChannel {
name: &'static str,
wasm: &'static [u8],
capabilities: &'static [u8],
}
/// Names of bundled channels shipped with IronClaw.
pub fn bundled_channel_names() -> &'static [&'static str] {
&["telegram"]
}
/// Install a bundled channel into a channels directory.
pub async fn install_bundled_channel(
name: &str,
target_dir: &Path,
force: bool,
) -> Result<(), String> {
let channel = bundled_channel(name)
.ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?;
fs::create_dir_all(target_dir)
.await
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
let wasm_path = target_dir.join(format!("{}.wasm", channel.name));
let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name));
let has_existing = wasm_path.exists() || caps_path.exists();
if has_existing && !force {
return Err(format!(
"Channel '{}' already exists at {}",
channel.name,
target_dir.display()
));
}
fs::write(&wasm_path, channel.wasm)
.await
.map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?;
fs::write(&caps_path, channel.capabilities)
.await
.map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?;
Ok(())
}
fn bundled_channel(name: &str) -> Option<BundledChannel> {
if name.eq_ignore_ascii_case("telegram") {
Some(BundledChannel {
name: "telegram",
wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"),
capabilities: include_bytes!(
"../../../channels-src/telegram/telegram.capabilities.json"
),
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use tokio::fs;
use super::*;
#[test]
fn test_bundled_channel_names_contains_telegram() {
assert!(bundled_channel_names().contains(&"telegram"));
}
#[tokio::test]
async fn test_install_bundled_channel_writes_files() {
let dir = tempdir().unwrap();
install_bundled_channel("telegram", dir.path(), false)
.await
.unwrap();
assert!(dir.path().join("telegram.wasm").exists());
assert!(dir.path().join("telegram.capabilities.json").exists());
}
#[tokio::test]
async fn test_install_bundled_channel_refuses_overwrite_without_force() {
let dir = tempdir().unwrap();
let wasm_path = dir.path().join("telegram.wasm");
fs::write(&wasm_path, b"custom").await.unwrap();
let result = install_bundled_channel("telegram", dir.path(), false).await;
assert!(result.is_err());
let existing = fs::read(&wasm_path).await.unwrap();
assert_eq!(existing, b"custom");
}
}
+2
View File
@@ -78,6 +78,7 @@
//! }
//! ```
mod bundled;
mod capabilities;
mod error;
mod host;
@@ -88,6 +89,7 @@ mod schema;
mod wrapper;
// Core types
pub use bundled::{bundled_channel_names, install_bundled_channel};
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
pub use error::WasmChannelError;
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
+2
View File
@@ -205,6 +205,8 @@ pub enum CredentialLocation {
},
/// Inject as a query parameter
QueryParam { name: String },
/// Inject by replacing a placeholder in URL or body templates
UrlPath { placeholder: String },
}
impl Default for CredentialLocation {
+167 -40
View File
@@ -9,13 +9,16 @@
//! 6. Channel configuration
//! 7. Heartbeat (background tasks)
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deadpool_postgres::{Config as PoolConfig, Runtime};
use secrecy::SecretString;
use tokio_postgres::NoTls;
use crate::channels::wasm::ChannelCapabilitiesFile;
use crate::channels::wasm::{
ChannelCapabilitiesFile, bundled_channel_names, install_bundled_channel,
};
use crate::llm::{SessionConfig, SessionManager};
use crate::secrets::SecretsCrypto;
use crate::settings::{KeySource, Settings};
@@ -535,6 +538,8 @@ impl SetupWizard {
.ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?;
self.test_database_connection(&url).await?;
// Ensure secrets-related tables exist for channels-only onboarding flows.
self.run_migrations().await?;
self.db_pool.clone().unwrap()
};
@@ -583,7 +588,12 @@ impl SetupWizard {
.unwrap_or_default()
.join(".ironclaw/channels");
let discovered_channels = discover_wasm_channels(&channels_dir).await;
let mut discovered_channels = discover_wasm_channels(&channels_dir).await;
let installed_names: HashSet<String> = discovered_channels
.iter()
.map(|(name, _)| name.clone())
.collect();
let wasm_channel_names = wasm_channel_option_names(&discovered_channels);
// Build options list dynamically
let mut options: Vec<(String, bool)> = vec![
@@ -594,8 +604,8 @@ impl SetupWizard {
),
];
// Add discovered WASM channels
for (name, _) in &discovered_channels {
// Add available WASM channels (installed + bundled)
for name in &wasm_channel_names {
let is_enabled = self.settings.channels.wasm_channels.contains(name);
let display_name = format!("{} (WASM)", capitalize_first(name));
options.push((display_name, is_enabled));
@@ -607,8 +617,33 @@ impl SetupWizard {
let selected = select_many("Which channels do you want to enable?", &options_refs)
.map_err(SetupError::Io)?;
let selected_wasm_channels: Vec<String> = wasm_channel_names
.iter()
.enumerate()
.filter_map(|(idx, name)| {
if selected.contains(&(idx + 2)) {
Some(name.clone())
} else {
None
}
})
.collect();
if let Some(installed) = install_selected_bundled_channels(
&channels_dir,
&selected_wasm_channels,
&installed_names,
)
.await?
{
if !installed.is_empty() {
print_success(&format!("Installed channels: {}", installed.join(", ")));
discovered_channels = discover_wasm_channels(&channels_dir).await;
}
}
// Determine if we need secrets context
let needs_secrets = selected.iter().any(|&i| i >= 1);
let needs_secrets = selected.contains(&1) || !selected_wasm_channels.is_empty();
let secrets = if needs_secrets {
match self.init_secrets_context().await {
Ok(ctx) => Some(ctx),
@@ -638,53 +673,53 @@ impl SetupWizard {
self.settings.channels.http_enabled = false;
}
// Process WASM channels (index 2 and above)
let mut enabled_wasm_channels = Vec::new();
for (idx, (channel_name, cap_file)) in discovered_channels.iter().enumerate() {
let option_idx = idx + 2; // Offset for CLI and HTTP
let discovered_by_name: HashMap<String, ChannelCapabilitiesFile> =
discovered_channels.into_iter().collect();
if selected.contains(&option_idx) {
println!();
if let Some(ref ctx) = secrets {
// Use setup schema from capabilities if available
let result = if !cap_file.setup.required_secrets.is_empty() {
setup_wasm_channel(ctx, channel_name, &cap_file.setup)
// Process selected WASM channels
let mut enabled_wasm_channels = Vec::new();
for channel_name in selected_wasm_channels {
println!();
if let Some(ref ctx) = secrets {
let result = if let Some(cap_file) = discovered_by_name.get(&channel_name) {
if !cap_file.setup.required_secrets.is_empty() {
setup_wasm_channel(ctx, &channel_name, &cap_file.setup)
.await
.map_err(SetupError::Channel)?
} else if channel_name == "telegram" {
let telegram_result = setup_telegram(ctx).await.map_err(SetupError::Channel)?;
crate::setup::channels::WasmChannelSetupResult {
enabled: telegram_result.enabled,
channel_name: "telegram".to_string(),
}
} else {
// Fall back to legacy Telegram setup for backwards compatibility
if channel_name == "telegram" {
let telegram_result =
setup_telegram(ctx).await.map_err(SetupError::Channel)?;
crate::setup::channels::WasmChannelSetupResult {
enabled: telegram_result.enabled,
channel_name: "telegram".to_string(),
}
} else {
print_info(&format!(
"No setup configuration found for {}",
channel_name
));
crate::setup::channels::WasmChannelSetupResult {
enabled: true,
channel_name: channel_name.to_string(),
}
print_info(&format!("No setup configuration found for {}", channel_name));
crate::setup::channels::WasmChannelSetupResult {
enabled: true,
channel_name: channel_name.clone(),
}
};
if result.enabled {
enabled_wasm_channels.push(result.channel_name);
}
} else {
// No secrets context, just enable the channel
print_info(&format!(
"{} enabled (configure tokens via environment)",
capitalize_first(channel_name)
"Channel '{}' is selected but not available on disk.",
channel_name
));
enabled_wasm_channels.push(channel_name.clone());
continue;
};
if result.enabled {
enabled_wasm_channels.push(result.channel_name);
}
} else {
// No secrets context, just enable the channel
print_info(&format!(
"{} enabled (configure tokens via environment)",
capitalize_first(&channel_name)
));
enabled_wasm_channels.push(channel_name.clone());
}
}
self.settings.channels.wasm_channels = enabled_wasm_channels;
Ok(())
@@ -932,8 +967,73 @@ fn capitalize_first(s: &str) -> String {
}
}
#[cfg(test)]
async fn install_missing_bundled_channels(
channels_dir: &std::path::Path,
already_installed: &HashSet<String>,
) -> Result<Vec<String>, SetupError> {
let mut installed = Vec::new();
for name in bundled_channel_names().iter().copied() {
if already_installed.contains(name) {
continue;
}
install_bundled_channel(name, channels_dir, false)
.await
.map_err(SetupError::Channel)?;
installed.push(name.to_string());
}
Ok(installed)
}
fn wasm_channel_option_names(discovered: &[(String, ChannelCapabilitiesFile)]) -> Vec<String> {
let mut names: Vec<String> = discovered.iter().map(|(name, _)| name.clone()).collect();
for bundled in bundled_channel_names().iter().copied() {
if !names.iter().any(|name| name == bundled) {
names.push(bundled.to_string());
}
}
names
}
async fn install_selected_bundled_channels(
channels_dir: &std::path::Path,
selected_channels: &[String],
already_installed: &HashSet<String>,
) -> Result<Option<Vec<String>>, SetupError> {
let bundled: HashSet<&str> = bundled_channel_names().iter().copied().collect();
let selected_missing: HashSet<String> = selected_channels
.iter()
.filter(|name| bundled.contains(name.as_str()) && !already_installed.contains(*name))
.cloned()
.collect();
if selected_missing.is_empty() {
return Ok(None);
}
let mut installed = Vec::new();
for name in selected_missing {
install_bundled_channel(&name, channels_dir, false)
.await
.map_err(SetupError::Channel)?;
installed.push(name);
}
installed.sort();
Ok(Some(installed))
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use tempfile::tempdir;
use super::*;
#[test]
@@ -973,4 +1073,31 @@ mod tests {
assert_eq!(capitalize_first("CAPS"), "CAPS");
assert_eq!(capitalize_first(""), "");
}
#[tokio::test]
async fn test_install_missing_bundled_channels_installs_telegram() {
let dir = tempdir().unwrap();
let installed = HashSet::<String>::new();
install_missing_bundled_channels(dir.path(), &installed)
.await
.unwrap();
assert!(dir.path().join("telegram.wasm").exists());
assert!(dir.path().join("telegram.capabilities.json").exists());
}
#[test]
fn test_wasm_channel_option_names_includes_bundled_when_missing() {
let discovered = Vec::new();
let options = wasm_channel_option_names(&discovered);
assert_eq!(options, vec!["telegram".to_string()]);
}
#[test]
fn test_wasm_channel_option_names_dedupes_bundled() {
let discovered = vec![(String::from("telegram"), ChannelCapabilitiesFile::default())];
let options = wasm_channel_option_names(&discovered);
assert_eq!(options, vec!["telegram".to_string()]);
}
}
+35
View File
@@ -241,6 +241,9 @@ pub enum CredentialLocationSchema {
/// Query parameter.
QueryParam { name: String },
/// URL/path placeholder replacement.
UrlPath { placeholder: String },
}
impl CredentialLocationSchema {
@@ -259,6 +262,9 @@ impl CredentialLocationSchema {
CredentialLocationSchema::QueryParam { name } => {
CredentialLocation::QueryParam { name: name.clone() }
}
CredentialLocationSchema::UrlPath { placeholder } => CredentialLocation::UrlPath {
placeholder: placeholder.clone(),
},
}
}
}
@@ -565,6 +571,35 @@ mod tests {
}
}
#[test]
fn test_parse_url_path_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.telegram.org" }],
"credentials": {
"telegram_bot": {
"secret_name": "telegram_bot_token",
"location": {
"type": "url_path",
"placeholder": "{TELEGRAM_BOT_TOKEN}"
},
"host_patterns": ["api.telegram.org"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("telegram_bot").unwrap();
match &cred.location {
CredentialLocationSchema::UrlPath { placeholder } => {
assert_eq!(placeholder, "{TELEGRAM_BOT_TOKEN}");
}
_ => panic!("Expected UrlPath location"),
}
}
#[test]
fn test_parse_secrets_capability() {
let json = r#"{
+4
View File
@@ -200,6 +200,10 @@ fn inject_credential(
.query_params
.insert(name.clone(), secret.expose().to_string());
}
CredentialLocation::UrlPath { .. } => {
// URL placeholder replacement is handled by channel/tool wrappers
// that substitute {PLACEHOLDER} values in templated strings.
}
}
}