mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Onboarding: select bundled Telegram channel and auto-install (#3)
Co-authored-by: Firat Sertgoz <[email protected]>
This commit is contained in:
co-authored by
Firat Sertgoz
parent
6bcc168ec5
commit
6831a54793
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"{
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user