Webhook and polling integrations for channels

This commit is contained in:
Illia Polosukhin
2026-02-04 22:02:13 -08:00
parent 7955c9742e
commit 0596a6c847
12 changed files with 1146 additions and 385 deletions
+115 -1
View File
@@ -330,7 +330,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Str
}
/// Generate a random webhook secret.
fn generate_webhook_secret() -> String {
pub fn generate_webhook_secret() -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
@@ -338,6 +338,120 @@ fn generate_webhook_secret() -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// Result of WASM channel setup.
#[derive(Debug, Clone)]
pub struct WasmChannelSetupResult {
pub enabled: bool,
pub channel_name: String,
}
/// Set up a WASM channel using its capabilities file setup schema.
///
/// Reads setup requirements from the channel's capabilities file and
/// prompts the user for each required secret.
pub async fn setup_wasm_channel(
secrets: &SecretsContext,
channel_name: &str,
setup: &crate::channels::wasm::SetupSchema,
) -> Result<WasmChannelSetupResult, String> {
println!("{} Setup:", channel_name);
println!();
for secret_config in &setup.required_secrets {
// Check if this secret already exists
if secrets.secret_exists(&secret_config.name).await {
print_info(&format!(
"Existing {} found in database.",
secret_config.name
));
if !confirm("Replace existing value?", false).map_err(|e| e.to_string())? {
continue;
}
}
// Get the value from user or auto-generate
let value = if secret_config.optional {
let input_value =
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))
.map_err(|e| e.to_string())?;
if let Some(v) = input_value {
if !v.is_empty() {
SecretString::from(v)
} else if let Some(ref auto_gen) = secret_config.auto_generate {
let generated = generate_secret_with_length(auto_gen.length);
print_info(&format!(
"Auto-generated {} ({} bytes)",
secret_config.name, auto_gen.length
));
SecretString::from(generated)
} else {
continue; // Skip optional secret with no auto-generate
}
} else if let Some(ref auto_gen) = secret_config.auto_generate {
let generated = generate_secret_with_length(auto_gen.length);
print_info(&format!(
"Auto-generated {} ({} bytes)",
secret_config.name, auto_gen.length
));
SecretString::from(generated)
} else {
continue; // Skip optional secret with no auto-generate
}
} else {
// Required secret
let input_value = secret_input(&secret_config.prompt).map_err(|e| e.to_string())?;
// Validate if pattern is provided
if let Some(ref pattern) = secret_config.validation {
let re = regex::Regex::new(pattern)
.map_err(|e| format!("Invalid validation pattern: {}", e))?;
if !re.is_match(input_value.expose_secret()) {
print_error(&format!(
"Value does not match expected format: {}",
pattern
));
return Err("Validation failed".to_string());
}
}
input_value
};
// Save the secret
secrets.save_secret(&secret_config.name, &value).await?;
print_success(&format!("{} saved to database", secret_config.name));
}
// Optionally validate the configuration
if let Some(ref validation_endpoint) = setup.validation_endpoint {
print_info("Validating configuration...");
// The validation endpoint may contain placeholders like {telegram_bot_token}
// For now, we skip validation since we'd need to substitute secrets
// A full implementation would fetch secrets and substitute them
print_info(&format!(
"Validation endpoint configured: {} (validation skipped)",
validation_endpoint
));
}
print_success(&format!("{} channel configured", channel_name));
Ok(WasmChannelSetupResult {
enabled: true,
channel_name: channel_name.to_string(),
})
}
/// Generate a random secret of specified length (in bytes).
fn generate_secret_with_length(length: usize) -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = vec![0u8; length];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
+151 -18
View File
@@ -11,10 +11,13 @@ use deadpool_postgres::{Config as PoolConfig, Runtime};
use secrecy::SecretString;
use tokio_postgres::NoTls;
use crate::channels::wasm::ChannelCapabilitiesFile;
use crate::llm::{SessionConfig, SessionManager};
use crate::secrets::SecretsCrypto;
use crate::settings::Settings;
use crate::setup::channels::{SecretsContext, setup_http, setup_telegram, setup_tunnel};
use crate::setup::channels::{
SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel,
};
use crate::setup::prompts::{
input, print_header, print_info, print_step, print_success, select_many, select_one,
};
@@ -330,16 +333,36 @@ impl SetupWizard {
}
println!();
let options = [
("CLI/TUI (always enabled)", true),
("HTTP webhook", self.settings.channels.http_enabled),
("Telegram", self.settings.channels.telegram_enabled),
// Discover available WASM channels
let channels_dir = dirs::home_dir()
.unwrap_or_default()
.join(".near-agent/channels");
let discovered_channels = discover_wasm_channels(&channels_dir).await;
// Build options list dynamically
let mut options: Vec<(String, bool)> = vec![
("CLI/TUI (always enabled)".to_string(), true),
(
"HTTP webhook".to_string(),
self.settings.channels.http_enabled,
),
];
let selected = select_many("Which channels do you want to enable?", &options)?;
// Add discovered WASM channels
for (name, _) in &discovered_channels {
let is_enabled = self.settings.channels.wasm_channels.contains(name);
let display_name = format!("{} (WASM)", capitalize_first(name));
options.push((display_name, is_enabled));
}
// Only initialize secrets context if we need it (HTTP or Telegram selected)
let needs_secrets = selected.contains(&1) || selected.contains(&2);
let options_refs: Vec<(&str, bool)> =
options.iter().map(|(s, b)| (s.as_str(), *b)).collect();
let selected = select_many("Which channels do you want to enable?", &options_refs)?;
// Determine if we need secrets context
let needs_secrets = selected.iter().any(|&i| i >= 1);
let secrets = if needs_secrets {
Some(self.init_secrets_context().await?)
} else {
@@ -358,16 +381,47 @@ impl SetupWizard {
self.settings.channels.http_enabled = false;
}
// Telegram is index 2
if selected.contains(&2) {
println!();
if let Some(ref ctx) = secrets {
let result = setup_telegram(ctx).await.map_err(SetupError::Channel)?;
self.settings.channels.telegram_enabled = result.enabled;
// 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
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)
.await
.map_err(SetupError::Channel)?
} 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(),
}
}
};
if result.enabled {
enabled_wasm_channels.push(result.channel_name);
}
}
}
} else {
self.settings.channels.telegram_enabled = false;
}
self.settings.channels.wasm_channels = enabled_wasm_channels;
Ok(())
}
@@ -407,13 +461,17 @@ impl SetupWizard {
println!(" - HTTP: enabled (port {})", port);
}
if self.settings.channels.telegram_enabled {
for channel_name in &self.settings.channels.wasm_channels {
let mode = if self.settings.tunnel.public_url.is_some() {
"webhook"
} else {
"polling"
};
println!(" - Telegram: enabled ({})", mode);
println!(
" - {}: enabled ({})",
capitalize_first(channel_name),
mode
);
}
println!();
@@ -440,6 +498,81 @@ impl Default for SetupWizard {
}
}
/// Discover WASM channels in a directory.
///
/// Returns a list of (channel_name, capabilities_file) pairs.
async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCapabilitiesFile)> {
let mut channels = Vec::new();
if !dir.is_dir() {
return channels;
}
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(e) => e,
Err(_) => return channels,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
// Look for .capabilities.json files
let extension = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !extension.ends_with(".capabilities.json") {
continue;
}
// Extract channel name
let name = extension.trim_end_matches(".capabilities.json").to_string();
if name.is_empty() {
continue;
}
// Check if corresponding .wasm file exists
let wasm_path = dir.join(format!("{}.wasm", name));
if !wasm_path.exists() {
continue;
}
// Parse capabilities file
match tokio::fs::read(&path).await {
Ok(bytes) => match ChannelCapabilitiesFile::from_bytes(&bytes) {
Ok(cap_file) => {
channels.push((name, cap_file));
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to parse channel capabilities file"
);
}
},
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to read channel capabilities file"
);
}
}
}
// Sort by name for consistent ordering
channels.sort_by(|a, b| a.0.cmp(&b.0));
channels
}
/// Capitalize the first letter of a string.
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;