mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
Compare commits
17
Commits
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*
|
||||||
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@ pretty_assertions = "1"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["postgres"]
|
default = ["postgres", "libsql"]
|
||||||
postgres = [
|
postgres = [
|
||||||
"dep:deadpool-postgres",
|
"dep:deadpool-postgres",
|
||||||
"dep:tokio-postgres",
|
"dep:tokio-postgres",
|
||||||
|
|||||||
@@ -338,7 +338,13 @@ fn emit_message(
|
|||||||
team_id,
|
team_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Error,
|
||||||
|
&format!("Failed to serialize Slack metadata: {}", e),
|
||||||
|
);
|
||||||
|
"{}".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
// Strip @ mentions of the bot from the text for cleaner messages
|
// Strip @ mentions of the bot from the text for cleaner messages
|
||||||
let cleaned_text = strip_bot_mention(&text);
|
let cleaned_text = strip_bot_mention(&text);
|
||||||
@@ -366,7 +372,13 @@ fn strip_bot_mention(text: &str) -> String {
|
|||||||
|
|
||||||
/// Create a JSON HTTP response.
|
/// Create a JSON HTTP response.
|
||||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Error,
|
||||||
|
&format!("Failed to serialize JSON response: {}", e),
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
});
|
||||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||||
|
|
||||||
OutgoingHttpResponse {
|
OutgoingHttpResponse {
|
||||||
|
|||||||
@@ -285,11 +285,7 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist dm_policy and allow_from for DM pairing in handle_message
|
// Persist dm_policy and allow_from for DM pairing in handle_message
|
||||||
let dm_policy = config
|
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
||||||
.dm_policy
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("pairing")
|
|
||||||
.to_string();
|
|
||||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
||||||
|
|
||||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||||
@@ -844,8 +840,8 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
|||||||
"parse_mode": "Markdown",
|
"parse_mode": "Markdown",
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&payload)
|
let payload_bytes =
|
||||||
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||||
|
|
||||||
let headers = serde_json::json!({
|
let headers = serde_json::json!({
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -915,15 +911,10 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
let is_private = message.chat.chat_type == "private";
|
let is_private = message.chat.chat_type == "private";
|
||||||
|
|
||||||
// Owner validation: when owner_id is set, only that user can message
|
// Owner validation: when owner_id is set, only that user can message
|
||||||
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
|
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||||
.map(|s| !s.is_empty())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if owner_configured {
|
if let Some(ref id_str) = owner_id_str {
|
||||||
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
|
if let Ok(owner_id) = id_str.parse::<i64>() {
|
||||||
.unwrap()
|
|
||||||
.parse::<i64>()
|
|
||||||
{
|
|
||||||
if from.id != owner_id {
|
if from.id != owner_id {
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Debug,
|
channel_host::LogLevel::Debug,
|
||||||
@@ -937,8 +928,8 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
}
|
}
|
||||||
} else if is_private {
|
} else if is_private {
|
||||||
// No owner_id: apply dm_policy for private chats
|
// No owner_id: apply dm_policy for private chats
|
||||||
let dm_policy = channel_host::workspace_read(DM_POLICY_PATH)
|
let dm_policy =
|
||||||
.unwrap_or_else(|| "pairing".to_string());
|
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||||
|
|
||||||
if dm_policy != "open" {
|
if dm_policy != "open" {
|
||||||
// Build effective allow list: config allow_from + pairing store
|
// Build effective allow list: config allow_from + pairing store
|
||||||
@@ -1001,8 +992,7 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
|
|
||||||
if !respond_to_all {
|
if !respond_to_all {
|
||||||
let has_command = content.starts_with('/');
|
let has_command = content.starts_with('/');
|
||||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let has_bot_mention = if bot_username.is_empty() {
|
let has_bot_mention = if bot_username.is_empty() {
|
||||||
content.contains('@')
|
content.contains('@')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -254,10 +254,19 @@ struct WhatsAppChannel;
|
|||||||
|
|
||||||
impl Guest for WhatsAppChannel {
|
impl Guest for WhatsAppChannel {
|
||||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||||
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
||||||
api_version: default_api_version(),
|
Ok(c) => c,
|
||||||
reply_to_message: default_reply_to_message(),
|
Err(e) => {
|
||||||
});
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Warn,
|
||||||
|
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
||||||
|
);
|
||||||
|
WhatsAppConfig {
|
||||||
|
api_version: default_api_version(),
|
||||||
|
reply_to_message: default_reply_to_message(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Info,
|
channel_host::LogLevel::Info,
|
||||||
@@ -267,6 +276,9 @@ impl Guest for WhatsAppChannel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Persist api_version in workspace so on_respond() can read it
|
||||||
|
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||||
|
|
||||||
// WhatsApp Cloud API is webhook-only, no polling available
|
// WhatsApp Cloud API is webhook-only, no polling available
|
||||||
Ok(ChannelConfig {
|
Ok(ChannelConfig {
|
||||||
display_name: "WhatsApp".to_string(),
|
display_name: "WhatsApp".to_string(),
|
||||||
@@ -327,11 +339,16 @@ impl Guest for WhatsAppChannel {
|
|||||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||||
|
|
||||||
|
// Read api_version from workspace (set during on_start), fallback to default
|
||||||
|
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "v18.0".to_string());
|
||||||
|
|
||||||
// Build WhatsApp API URL with token placeholder
|
// Build WhatsApp API URL with token placeholder
|
||||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||||
let api_url = format!(
|
let api_url = format!(
|
||||||
"https://graph.facebook.com/v18.0/{}/messages",
|
"https://graph.facebook.com/{}/{}/messages",
|
||||||
metadata.phone_number_id
|
api_version, metadata.phone_number_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build sendMessage payload
|
// Build sendMessage payload
|
||||||
|
|||||||
+81
-5
@@ -81,17 +81,34 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
/// Write database bootstrap vars to `~/.ironclaw/.env`.
|
||||||
|
///
|
||||||
|
/// These settings form the chicken-and-egg layer: they must be available
|
||||||
|
/// from the filesystem (env vars) BEFORE any database connection, because
|
||||||
|
/// they determine which database to connect to. Everything else is stored
|
||||||
|
/// in the database itself.
|
||||||
///
|
///
|
||||||
/// Creates the parent directory if it doesn't exist.
|
/// Creates the parent directory if it doesn't exist.
|
||||||
/// The value is double-quoted so that `#` (common in URL-encoded passwords)
|
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||||
/// and other shell-special characters are preserved by dotenvy.
|
/// and other shell-special characters are preserved by dotenvy.
|
||||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||||
let path = ironclaw_env_path();
|
let path = ironclaw_env_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url))
|
let mut content = String::new();
|
||||||
|
for (key, value) in vars {
|
||||||
|
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||||
|
}
|
||||||
|
std::fs::write(&path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||||
|
///
|
||||||
|
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
|
||||||
|
/// paths. Prefer `save_bootstrap_env` for new code.
|
||||||
|
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||||
|
save_bootstrap_env(&[("DATABASE_URL", url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||||
@@ -184,7 +201,7 @@ pub async fn migrate_disk_to_db(
|
|||||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
store
|
store
|
||||||
.set_setting(user_id, "nearai.session", &value)
|
.set_setting(user_id, "nearai.session_token", &value)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
MigrationError::Database(format!(
|
MigrationError::Database(format!(
|
||||||
@@ -385,4 +402,63 @@ mod tests {
|
|||||||
// Nothing should happen
|
// Nothing should happen
|
||||||
assert!(!env_path.exists());
|
assert!(!env_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_bootstrap_env_multiple_vars() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let env_path = dir.path().join("nested").join(".env");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let vars = [
|
||||||
|
("DATABASE_BACKEND", "libsql"),
|
||||||
|
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Write manually to the temp path (save_bootstrap_env uses the global path)
|
||||||
|
let mut content = String::new();
|
||||||
|
for (key, value) in &vars {
|
||||||
|
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||||
|
}
|
||||||
|
std::fs::write(&env_path, &content).unwrap();
|
||||||
|
|
||||||
|
// Verify dotenvy can parse all entries
|
||||||
|
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(parsed.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
parsed[0],
|
||||||
|
("DATABASE_BACKEND".to_string(), "libsql".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed[1],
|
||||||
|
(
|
||||||
|
"LIBSQL_PATH".to_string(),
|
||||||
|
"/home/user/.ironclaw/ironclaw.db".to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_bootstrap_env_overwrites_previous() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let env_path = dir.path().join(".env");
|
||||||
|
|
||||||
|
// Write initial content
|
||||||
|
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
|
||||||
|
|
||||||
|
// Overwrite with new vars (simulating save_bootstrap_env behavior)
|
||||||
|
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
|
||||||
|
std::fs::write(&env_path, content).unwrap();
|
||||||
|
|
||||||
|
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
// Old DATABASE_URL should be gone
|
||||||
|
assert_eq!(parsed.len(), 2);
|
||||||
|
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,14 +80,13 @@ pub enum OAuthCallbackError {
|
|||||||
|
|
||||||
/// Bind the OAuth callback listener on the fixed port.
|
/// Bind the OAuth callback listener on the fixed port.
|
||||||
///
|
///
|
||||||
/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects
|
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
|
||||||
/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4
|
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
|
||||||
/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse`
|
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
|
||||||
/// (e.g., IPv6 not supported on the host). If the port is already occupied
|
/// than `AddrInUse`. If the port is already occupied, fails immediately.
|
||||||
/// on IPv6, the port is occupied period, so we fail immediately.
|
|
||||||
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
||||||
let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT);
|
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
|
||||||
match TcpListener::bind(&ipv6_addr).await {
|
match TcpListener::bind(&ipv4_addr).await {
|
||||||
Ok(listener) => return Ok(listener),
|
Ok(listener) => return Ok(listener),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||||
return Err(OAuthCallbackError::PortInUse(
|
return Err(OAuthCallbackError::PortInUse(
|
||||||
@@ -96,10 +95,10 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// IPv6 not available on this host, fall back to IPv4
|
// IPv4 not available, fall back to IPv6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT))
|
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
|
|||||||
+36
-14
@@ -22,15 +22,36 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
let db_url_set = std::env::var("DATABASE_URL").is_ok();
|
|
||||||
print!(" Database: ");
|
print!(" Database: ");
|
||||||
if db_url_set {
|
let db_backend = std::env::var("DATABASE_BACKEND")
|
||||||
match check_database().await {
|
.ok()
|
||||||
Ok(()) => println!("connected"),
|
.unwrap_or_else(|| "postgres".to_string());
|
||||||
Err(e) => println!("error ({})", e),
|
match db_backend.as_str() {
|
||||||
|
"libsql" | "turso" | "sqlite" => {
|
||||||
|
let path = std::env::var("LIBSQL_PATH")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
||||||
|
if path.exists() {
|
||||||
|
let turso = if std::env::var("LIBSQL_URL").is_ok() {
|
||||||
|
" + Turso sync"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!("libSQL ({}{})", path.display(), turso);
|
||||||
|
} else {
|
||||||
|
println!("libSQL (file missing: {})", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if std::env::var("DATABASE_URL").is_ok() {
|
||||||
|
match check_database().await {
|
||||||
|
Ok(()) => println!("connected (PostgreSQL)"),
|
||||||
|
Err(e) => println!("error ({})", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("not configured");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
println!("not configured");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session / Auth
|
// Session / Auth
|
||||||
@@ -42,16 +63,17 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
println!("not found (run `ironclaw onboard`)");
|
println!("not found (run `ironclaw onboard`)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (auto-detect: env var or keychain)
|
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
||||||
|
// triggering macOS system password dialogs on a simple status check)
|
||||||
print!(" Secrets: ");
|
print!(" Secrets: ");
|
||||||
let has_env_key = std::env::var("SECRETS_MASTER_KEY").is_ok();
|
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||||
let has_keychain = crate::secrets::keychain::has_master_key().await;
|
|
||||||
if has_env_key {
|
|
||||||
println!("configured (env)");
|
println!("configured (env)");
|
||||||
} else if has_keychain {
|
|
||||||
println!("configured (keychain)");
|
|
||||||
} else {
|
} else {
|
||||||
println!("not configured");
|
// We don't probe the keychain here because get_generic_password()
|
||||||
|
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
||||||
|
// a read-only status command. If onboarding completed with keychain
|
||||||
|
// storage, the key is there; we just can't cheaply verify it.
|
||||||
|
println!("env not set (keychain may be configured)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embeddings
|
// Embeddings
|
||||||
|
|||||||
+84
-9
@@ -5,7 +5,9 @@
|
|||||||
//! in startup). Everything else comes from env vars, the DB settings
|
//! in startup). Everything else comes from env vars, the DB settings
|
||||||
//! table, or auto-detection.
|
//! table, or auto-detection.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
@@ -13,6 +15,13 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||||
|
///
|
||||||
|
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
||||||
|
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
||||||
|
/// real env vars first, then falls back to this overlay.
|
||||||
|
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||||
|
|
||||||
/// Main configuration for the agent.
|
/// Main configuration for the agent.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -402,12 +411,24 @@ pub struct NearAiConfig {
|
|||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
// Determine backend (default: NearAi)
|
// Determine backend: env var > settings > default (NearAi)
|
||||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||||
key: "LLM_BACKEND".to_string(),
|
key: "LLM_BACKEND".to_string(),
|
||||||
message: e,
|
message: e,
|
||||||
})?
|
})?
|
||||||
|
} else if let Some(ref b) = settings.llm_backend {
|
||||||
|
match b.parse() {
|
||||||
|
Ok(backend) => backend,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
|
||||||
|
b,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
LlmBackend::NearAi
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LlmBackend::NearAi
|
LlmBackend::NearAi
|
||||||
};
|
};
|
||||||
@@ -476,6 +497,7 @@ impl LlmConfig {
|
|||||||
|
|
||||||
let ollama = if backend == LlmBackend::Ollama {
|
let ollama = if backend == LlmBackend::Ollama {
|
||||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||||
|
.or_else(|| settings.ollama_base_url.clone())
|
||||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||||
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||||
Some(OllamaConfig { base_url, model })
|
Some(OllamaConfig { base_url, model })
|
||||||
@@ -484,8 +506,9 @@ impl LlmConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||||
let base_url =
|
let base_url = optional_env("LLM_BASE_URL")?
|
||||||
optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired {
|
.or_else(|| settings.openai_compatible_base_url.clone())
|
||||||
|
.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
key: "LLM_BASE_URL".to_string(),
|
key: "LLM_BASE_URL".to_string(),
|
||||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -855,6 +878,11 @@ impl std::fmt::Debug for SecretsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Process-wide cache for the keychain master key.
|
||||||
|
///
|
||||||
|
/// Avoids re-prompting the OS keychain on every `SecretsConfig::resolve()` call
|
||||||
|
/// (e.g. `Config::from_env()` then `Config::from_db()`). Thread-safe alternative
|
||||||
|
/// to caching in a process env var.
|
||||||
impl SecretsConfig {
|
impl SecretsConfig {
|
||||||
/// Auto-detect secrets master key from env var, then OS keychain.
|
/// Auto-detect secrets master key from env var, then OS keychain.
|
||||||
///
|
///
|
||||||
@@ -1338,17 +1366,64 @@ impl ClaudeCodeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load API keys from the encrypted secrets store into a thread-safe overlay.
|
||||||
|
///
|
||||||
|
/// This bridges the gap between secrets stored during onboarding and the
|
||||||
|
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
||||||
|
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||||
|
/// so explicit env vars always win.
|
||||||
|
pub async fn inject_llm_keys_from_secrets(
|
||||||
|
secrets: &dyn crate::secrets::SecretsStore,
|
||||||
|
user_id: &str,
|
||||||
|
) {
|
||||||
|
let mappings = [
|
||||||
|
("llm_openai_api_key", "OPENAI_API_KEY"),
|
||||||
|
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||||
|
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut injected = HashMap::new();
|
||||||
|
|
||||||
|
for (secret_name, env_var) in mappings {
|
||||||
|
match std::env::var(env_var) {
|
||||||
|
Ok(val) if !val.is_empty() => continue,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match secrets.get_decrypted(user_id, secret_name).await {
|
||||||
|
Ok(decrypted) => {
|
||||||
|
injected.insert(env_var.to_string(), decrypted.expose().to_string());
|
||||||
|
tracing::debug!("Loaded secret '{}' for env var '{}'", secret_name, env_var);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Secret doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = INJECTED_VARS.set(injected);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
||||||
|
// Check real env vars first (always win over injected secrets)
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(val) if val.is_empty() => Ok(None),
|
Ok(val) if val.is_empty() => {}
|
||||||
Ok(val) => Ok(Some(val)),
|
Ok(val) => return Ok(Some(val)),
|
||||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
Err(std::env::VarError::NotPresent) => {}
|
||||||
Err(e) => Err(ConfigError::ParseError(format!(
|
Err(e) => {
|
||||||
"failed to read {key}: {e}"
|
return Err(ConfigError::ParseError(format!(
|
||||||
))),
|
"failed to read {key}: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to thread-safe overlay (secrets injected from DB)
|
||||||
|
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
||||||
|
return Ok(Some(val.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
|
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
|
||||||
|
|||||||
+66
-44
@@ -48,7 +48,6 @@ use ironclaw::secrets::PostgresSecretsStore;
|
|||||||
use ironclaw::secrets::SecretsCrypto;
|
use ironclaw::secrets::SecretsCrypto;
|
||||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||||
use ironclaw::setup::{SetupConfig, SetupWizard};
|
use ironclaw::setup::{SetupConfig, SetupWizard};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -444,6 +443,72 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create secrets store early: needed for injecting LLM API keys from encrypted
|
||||||
|
// storage before creating the LLM provider, and later for MCP auth + WASM channels.
|
||||||
|
//
|
||||||
|
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
||||||
|
// backend determines which store is created: whichever DB init branch ran will
|
||||||
|
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
||||||
|
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||||
|
if let Some(master_key) = config.secrets.master_key() {
|
||||||
|
match SecretsCrypto::new(master_key.clone()) {
|
||||||
|
Ok(crypto) => {
|
||||||
|
let crypto = Arc::new(crypto);
|
||||||
|
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let store = store.or_else(|| {
|
||||||
|
libsql_db.take().map(|db| {
|
||||||
|
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
||||||
|
as Arc<dyn SecretsStore + Send + Sync>
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
let store = store.or_else(|| {
|
||||||
|
pg_pool.as_ref().map(|pool| {
|
||||||
|
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
||||||
|
as Arc<dyn SecretsStore + Send + Sync>
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
store
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let _ = libsql_db.take();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let _ = libsql_db.take();
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject LLM API keys from the encrypted secrets store into a thread-safe
|
||||||
|
// overlay so that optional_env() (used by LlmConfig::resolve()) picks them
|
||||||
|
// up. Then re-resolve LlmConfig with the newly available keys (backend may
|
||||||
|
// have been set during onboarding but the API key is in the secrets store).
|
||||||
|
if let Some(ref secrets) = secrets_store {
|
||||||
|
ironclaw::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
|
||||||
|
|
||||||
|
// Re-resolve LlmConfig now that secrets overlay has been populated
|
||||||
|
if let Some(ref db_ref) = db {
|
||||||
|
match Config::from_db(db_ref.as_ref(), "default").await {
|
||||||
|
Ok(refreshed) => {
|
||||||
|
config = refreshed;
|
||||||
|
tracing::debug!("LlmConfig re-resolved after secret injection");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to re-resolve config after secret injection: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize LLM provider (clone session so we can reuse it for embeddings)
|
// Initialize LLM provider (clone session so we can reuse it for embeddings)
|
||||||
let llm = create_llm_provider(&config.llm, session.clone())?;
|
let llm = create_llm_provider(&config.llm, session.clone())?;
|
||||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||||
@@ -542,49 +607,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Builder mode enabled");
|
tracing::info!("Builder mode enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secrets store if master key is configured (needed for MCP auth and WASM channels).
|
|
||||||
//
|
|
||||||
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
|
||||||
// backend determines which store is created: whichever DB init branch ran will
|
|
||||||
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
|
||||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
|
||||||
if let Some(master_key) = config.secrets.master_key() {
|
|
||||||
match SecretsCrypto::new(master_key.clone()) {
|
|
||||||
Ok(crypto) => {
|
|
||||||
let crypto = Arc::new(crypto);
|
|
||||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let store = store.or_else(|| {
|
|
||||||
libsql_db.take().map(|db| {
|
|
||||||
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
|
||||||
as Arc<dyn SecretsStore + Send + Sync>
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
let store = store.or_else(|| {
|
|
||||||
pg_pool.as_ref().map(|pool| {
|
|
||||||
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
|
||||||
as Arc<dyn SecretsStore + Send + Sync>
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
store
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let _ = libsql_db.take();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let _ = libsql_db.take();
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||||
|
|
||||||
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
||||||
|
|||||||
+57
-11
@@ -40,8 +40,18 @@ pub struct Settings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub secrets_master_key_source: KeySource,
|
pub secrets_master_key_source: KeySource,
|
||||||
|
|
||||||
// === Step 3: NEAR AI Auth ===
|
// === Step 3: Inference Provider ===
|
||||||
// Session stored separately in session.json
|
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
|
||||||
|
#[serde(default)]
|
||||||
|
pub llm_backend: Option<String>,
|
||||||
|
|
||||||
|
/// Ollama base URL (when llm_backend = "ollama").
|
||||||
|
#[serde(default)]
|
||||||
|
pub ollama_base_url: Option<String>,
|
||||||
|
|
||||||
|
/// OpenAI-compatible endpoint base URL (when llm_backend = "openai_compatible").
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_compatible_base_url: Option<String>,
|
||||||
|
|
||||||
// === Step 4: Model Selection ===
|
// === Step 4: Model Selection ===
|
||||||
/// Currently selected model.
|
/// Currently selected model.
|
||||||
@@ -504,7 +514,11 @@ impl Settings {
|
|||||||
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
||||||
/// Missing keys get their default value.
|
/// Missing keys get their default value.
|
||||||
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
||||||
// Start with defaults, then overlay each DB setting
|
// Start with defaults, then overlay each DB setting.
|
||||||
|
//
|
||||||
|
// The settings table stores both Settings struct fields and app-specific
|
||||||
|
// data (e.g. nearai.session_token). Skip keys that don't correspond to
|
||||||
|
// a known Settings path.
|
||||||
let mut settings = Self::default();
|
let mut settings = Self::default();
|
||||||
|
|
||||||
for (key, value) in map {
|
for (key, value) in map {
|
||||||
@@ -513,17 +527,23 @@ impl Settings {
|
|||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
serde_json::Value::Bool(b) => b.to_string(),
|
serde_json::Value::Bool(b) => b.to_string(),
|
||||||
serde_json::Value::Number(n) => n.to_string(),
|
serde_json::Value::Number(n) => n.to_string(),
|
||||||
serde_json::Value::Null => "null".to_string(),
|
serde_json::Value::Null => continue, // null means default, skip
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = settings.set(key, &value_str) {
|
match settings.set(key, &value_str) {
|
||||||
tracing::warn!(
|
Ok(()) => {}
|
||||||
"Failed to apply DB setting '{}' = '{}': {}",
|
// The settings table stores both Settings fields and app-specific
|
||||||
key,
|
// data (e.g. nearai.session_token). Silently skip unknown paths.
|
||||||
value_str,
|
Err(e) if e.starts_with("Path not found") => {}
|
||||||
e
|
Err(e) => {
|
||||||
);
|
tracing::warn!(
|
||||||
|
"Failed to apply DB setting '{}' = '{}': {}",
|
||||||
|
key,
|
||||||
|
value_str,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,4 +878,30 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_llm_backend_round_trip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("settings.json");
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
ollama_base_url: Some("http://localhost:11434".to_string()),
|
||||||
|
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
|
||||||
|
let loaded = Settings::load_from(&path);
|
||||||
|
assert_eq!(loaded.llm_backend, Some("anthropic".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
loaded.ollama_base_url,
|
||||||
|
Some("http://localhost:11434".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.openai_compatible_base_url,
|
||||||
|
Some("http://my-vllm:8000/v1".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-106
@@ -20,6 +20,22 @@ use crate::setup::prompts::{
|
|||||||
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Typed errors for channel setup flows.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ChannelSetupError {
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Network(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Secrets(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Context for saving secrets during setup.
|
/// Context for saving secrets during setup.
|
||||||
pub struct SecretsContext {
|
pub struct SecretsContext {
|
||||||
store: Arc<dyn SecretsStore>,
|
store: Arc<dyn SecretsStore>,
|
||||||
@@ -45,32 +61,39 @@ impl SecretsContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save a secret to the database.
|
/// Save a secret to the database.
|
||||||
pub async fn save_secret(&self, name: &str, value: &SecretString) -> Result<(), String> {
|
pub async fn save_secret(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
value: &SecretString,
|
||||||
|
) -> Result<(), ChannelSetupError> {
|
||||||
let params = CreateSecretParams::new(name, value.expose_secret());
|
let params = CreateSecretParams::new(name, value.expose_secret());
|
||||||
|
|
||||||
self.store
|
self.store
|
||||||
.create(&self.user_id, params)
|
.create(&self.user_id, params)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to save secret: {}", e))?;
|
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to save secret: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a secret exists.
|
/// Check if a secret exists.
|
||||||
pub async fn secret_exists(&self, name: &str) -> bool {
|
pub async fn secret_exists(&self, name: &str) -> bool {
|
||||||
self.store
|
match self.store.exists(&self.user_id, name).await {
|
||||||
.exists(&self.user_id, name)
|
Ok(exists) => exists,
|
||||||
.await
|
Err(e) => {
|
||||||
.unwrap_or(false)
|
tracing::warn!(secret = name, error = %e, "Failed to check if secret exists, assuming absent");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a secret from the database (decrypted).
|
/// Read a secret from the database (decrypted).
|
||||||
pub async fn get_secret(&self, name: &str) -> Result<SecretString, String> {
|
pub async fn get_secret(&self, name: &str) -> Result<SecretString, ChannelSetupError> {
|
||||||
let decrypted = self
|
let decrypted = self
|
||||||
.store
|
.store
|
||||||
.get_decrypted(&self.user_id, name)
|
.get_decrypted(&self.user_id, name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to read secret: {}", e))?;
|
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to read secret: {}", e)))?;
|
||||||
Ok(SecretString::from(decrypted.expose().to_string()))
|
Ok(SecretString::from(decrypted.expose().to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +130,6 @@ struct TelegramGetUpdatesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TelegramUpdate {
|
struct TelegramUpdate {
|
||||||
#[allow(dead_code)]
|
|
||||||
update_id: i64,
|
update_id: i64,
|
||||||
message: Option<TelegramUpdateMessage>,
|
message: Option<TelegramUpdateMessage>,
|
||||||
}
|
}
|
||||||
@@ -134,7 +156,7 @@ struct TelegramUpdateUser {
|
|||||||
pub async fn setup_telegram(
|
pub async fn setup_telegram(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
) -> Result<TelegramSetupResult, String> {
|
) -> Result<TelegramSetupResult, ChannelSetupError> {
|
||||||
println!("Telegram Setup:");
|
println!("Telegram Setup:");
|
||||||
println!();
|
println!();
|
||||||
print_info("To create a Telegram bot:");
|
print_info("To create a Telegram bot:");
|
||||||
@@ -146,7 +168,7 @@ pub async fn setup_telegram(
|
|||||||
// Check if token already exists
|
// Check if token already exists
|
||||||
if secrets.secret_exists("telegram_bot_token").await {
|
if secrets.secret_exists("telegram_bot_token").await {
|
||||||
print_info("Existing Telegram token found in database.");
|
print_info("Existing Telegram token found in database.");
|
||||||
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
|
if !confirm("Replace existing token?", false)? {
|
||||||
// Still offer to configure webhook secret and owner binding
|
// Still offer to configure webhook secret and owner binding
|
||||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||||
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
||||||
@@ -159,47 +181,48 @@ pub async fn setup_telegram(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = secret_input("Bot token (from @BotFather)").map_err(|e| e.to_string())?;
|
loop {
|
||||||
|
let token = secret_input("Bot token (from @BotFather)")?;
|
||||||
|
|
||||||
// Validate the token
|
// Validate the token
|
||||||
print_info("Validating bot token...");
|
print_info("Validating bot token...");
|
||||||
|
|
||||||
match validate_telegram_token(&token).await {
|
match validate_telegram_token(&token).await {
|
||||||
Ok(username) => {
|
Ok(username) => {
|
||||||
print_success(&format!(
|
print_success(&format!(
|
||||||
"Bot validated: @{}",
|
"Bot validated: @{}",
|
||||||
username.as_deref().unwrap_or("unknown")
|
username.as_deref().unwrap_or("unknown")
|
||||||
));
|
));
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
secrets.save_secret("telegram_bot_token", &token).await?;
|
secrets.save_secret("telegram_bot_token", &token).await?;
|
||||||
print_success("Token saved to database");
|
print_success("Token saved to database");
|
||||||
|
|
||||||
// Bind bot to owner's Telegram account
|
// Bind bot to owner's Telegram account
|
||||||
let owner_id = bind_telegram_owner(&token).await?;
|
let owner_id = bind_telegram_owner(&token).await?;
|
||||||
|
|
||||||
// Offer webhook secret configuration
|
// Offer webhook secret configuration
|
||||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
let webhook_secret =
|
||||||
|
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||||
|
|
||||||
Ok(TelegramSetupResult {
|
return Ok(TelegramSetupResult {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
bot_username: username,
|
bot_username: username,
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
owner_id,
|
owner_id,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
print_error(&format!("Token validation failed: {}", e));
|
print_error(&format!("Token validation failed: {}", e));
|
||||||
|
|
||||||
if confirm("Try again?", true).map_err(|e| e.to_string())? {
|
if !confirm("Try again?", true)? {
|
||||||
Box::pin(setup_telegram(secrets, settings)).await
|
return Ok(TelegramSetupResult {
|
||||||
} else {
|
enabled: false,
|
||||||
Ok(TelegramSetupResult {
|
bot_username: None,
|
||||||
enabled: false,
|
webhook_secret: None,
|
||||||
bot_username: None,
|
owner_id: None,
|
||||||
webhook_secret: None,
|
});
|
||||||
owner_id: None,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,14 +232,14 @@ pub async fn setup_telegram(
|
|||||||
///
|
///
|
||||||
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
||||||
/// Returns `None` if the user declines or the flow times out.
|
/// Returns `None` if the user declines or the flow times out.
|
||||||
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String> {
|
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
|
||||||
println!();
|
println!();
|
||||||
print_info("Account Binding (recommended):");
|
print_info("Account Binding (recommended):");
|
||||||
print_info("Binding restricts the bot so only YOU can use it.");
|
print_info("Binding restricts the bot so only YOU can use it.");
|
||||||
print_info("Without this, anyone who finds your bot can send it messages.");
|
print_info("Without this, anyone who finds your bot can send it messages.");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? {
|
if !confirm("Bind bot to your Telegram account?", true)? {
|
||||||
print_info("Skipping account binding. Bot will accept messages from all users.");
|
print_info("Skipping account binding. Bot will accept messages from all users.");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -227,14 +250,16 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(35))
|
.timeout(std::time::Duration::from_secs(35))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
// Clear any existing webhook so getUpdates works
|
// Clear any existing webhook so getUpdates works
|
||||||
let delete_url = format!(
|
let delete_url = format!(
|
||||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||||
token.expose_secret()
|
token.expose_secret()
|
||||||
);
|
);
|
||||||
let _ = client.post(&delete_url).send().await;
|
if let Err(e) = client.post(&delete_url).send().await {
|
||||||
|
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let updates_url = format!(
|
let updates_url = format!(
|
||||||
"https://api.telegram.org/bot{}/getUpdates",
|
"https://api.telegram.org/bot{}/getUpdates",
|
||||||
@@ -249,19 +274,23 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("getUpdates request failed: {}", e))?;
|
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("getUpdates returned status {}", response.status()));
|
return Err(ChannelSetupError::Network(format!(
|
||||||
|
"getUpdates returned status {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: TelegramGetUpdatesResponse = response
|
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
|
||||||
.json()
|
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| format!("Failed to parse getUpdates response: {}", e))?;
|
|
||||||
|
|
||||||
if !body.ok {
|
if !body.ok {
|
||||||
return Err("Telegram API returned error for getUpdates".to_string());
|
return Err(ChannelSetupError::Network(
|
||||||
|
"Telegram API returned error for getUpdates".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first message with a sender
|
// Find the first message with a sender
|
||||||
@@ -285,11 +314,14 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
"https://api.telegram.org/bot{}/getUpdates",
|
"https://api.telegram.org/bot{}/getUpdates",
|
||||||
token.expose_secret()
|
token.expose_secret()
|
||||||
);
|
);
|
||||||
let _ = client
|
if let Err(e) = client
|
||||||
.get(&ack_url)
|
.get(&ack_url)
|
||||||
.query(&[("offset", &(update.update_id + 1).to_string())])
|
.query(&[("offset", &(update.update_id + 1).to_string())])
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Failed to acknowledge Telegram update: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(Some(from.id));
|
return Ok(Some(from.id));
|
||||||
}
|
}
|
||||||
@@ -307,10 +339,10 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
async fn bind_telegram_owner_flow(
|
async fn bind_telegram_owner_flow(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
) -> Result<Option<i64>, String> {
|
) -> Result<Option<i64>, ChannelSetupError> {
|
||||||
if settings.channels.telegram_owner_id.is_some() {
|
if settings.channels.telegram_owner_id.is_some() {
|
||||||
print_info("Bot is already bound to a Telegram account.");
|
print_info("Bot is already bound to a Telegram account.");
|
||||||
if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? {
|
if !confirm("Re-bind to a different account?", false)? {
|
||||||
return Ok(settings.channels.telegram_owner_id);
|
return Ok(settings.channels.telegram_owner_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,10 +357,10 @@ async fn bind_telegram_owner_flow(
|
|||||||
///
|
///
|
||||||
/// This is shared across all channels that need webhook endpoints.
|
/// This is shared across all channels that need webhook endpoints.
|
||||||
/// Returns the tunnel URL if configured.
|
/// Returns the tunnel URL if configured.
|
||||||
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
|
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupError> {
|
||||||
if let Some(ref url) = settings.tunnel.public_url {
|
if let Some(ref url) = settings.tunnel.public_url {
|
||||||
print_info(&format!("Existing tunnel configured: {}", url));
|
print_info(&format!("Existing tunnel configured: {}", url));
|
||||||
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
|
if !confirm("Change tunnel configuration?", false)? {
|
||||||
return Ok(Some(url.clone()));
|
return Ok(Some(url.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,17 +380,18 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
|
|||||||
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? {
|
if !confirm("Configure a tunnel?", false)? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tunnel_url =
|
let tunnel_url = input("Tunnel URL (e.g., https://abc123.ngrok.io)")?;
|
||||||
input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Validate URL format
|
// Validate URL format
|
||||||
if !tunnel_url.starts_with("https://") {
|
if !tunnel_url.starts_with("https://") {
|
||||||
print_error("URL must start with https:// (webhooks require HTTPS)");
|
print_error("URL must start with https:// (webhooks require HTTPS)");
|
||||||
return Err("Invalid tunnel URL: must use HTTPS".to_string());
|
return Err(ChannelSetupError::Validation(
|
||||||
|
"Invalid tunnel URL: must use HTTPS".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing slash if present
|
// Remove trailing slash if present
|
||||||
@@ -378,7 +411,7 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
|
|||||||
async fn setup_telegram_webhook_secret(
|
async fn setup_telegram_webhook_secret(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
tunnel: &TunnelSettings,
|
tunnel: &TunnelSettings,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, ChannelSetupError> {
|
||||||
if tunnel.public_url.is_none() {
|
if tunnel.public_url.is_none() {
|
||||||
print_info("");
|
print_info("");
|
||||||
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
||||||
@@ -391,7 +424,7 @@ async fn setup_telegram_webhook_secret(
|
|||||||
print_info("A webhook secret adds an extra layer of security by validating");
|
print_info("A webhook secret adds an extra layer of security by validating");
|
||||||
print_info("that requests actually come from Telegram's servers.");
|
print_info("that requests actually come from Telegram's servers.");
|
||||||
|
|
||||||
if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? {
|
if !confirm("Generate a webhook secret?", true)? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,11 +443,13 @@ async fn setup_telegram_webhook_secret(
|
|||||||
/// Validate a Telegram bot token by calling the getMe API.
|
/// Validate a Telegram bot token by calling the getMe API.
|
||||||
///
|
///
|
||||||
/// Returns the bot's username if valid.
|
/// Returns the bot's username if valid.
|
||||||
pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<String>, String> {
|
pub async fn validate_telegram_token(
|
||||||
|
token: &SecretString,
|
||||||
|
) -> Result<Option<String>, ChannelSetupError> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://api.telegram.org/bot{}/getMe",
|
"https://api.telegram.org/bot{}/getMe",
|
||||||
@@ -425,21 +460,26 @@ pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<Stri
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Request failed: {}", e))?;
|
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("API returned status {}", response.status()));
|
return Err(ChannelSetupError::Network(format!(
|
||||||
|
"API returned status {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: TelegramGetMeResponse = response
|
let body: TelegramGetMeResponse = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
|
||||||
|
|
||||||
if body.ok {
|
if body.ok {
|
||||||
Ok(body.result.and_then(|u| u.username))
|
Ok(body.result.and_then(|u| u.username))
|
||||||
} else {
|
} else {
|
||||||
Err("Telegram API returned error".to_string())
|
Err(ChannelSetupError::Network(
|
||||||
|
"Telegram API returned error".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,38 +492,34 @@ pub struct HttpSetupResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set up HTTP webhook channel.
|
/// Set up HTTP webhook channel.
|
||||||
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, String> {
|
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, ChannelSetupError> {
|
||||||
println!("HTTP Webhook Setup:");
|
println!("HTTP Webhook Setup:");
|
||||||
println!();
|
println!();
|
||||||
print_info("The HTTP webhook allows external services to send messages to the agent.");
|
print_info("The HTTP webhook allows external services to send messages to the agent.");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let port_str = optional_input("Port", Some("default: 8080")).map_err(|e| e.to_string())?;
|
let port_str = optional_input("Port", Some("default: 8080"))?;
|
||||||
let port: u16 = port_str
|
let port: u16 = port_str
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("8080")
|
.unwrap_or("8080")
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| format!("Invalid port: {}", e))?;
|
.map_err(|e| ChannelSetupError::Validation(format!("Invalid port: {}", e)))?;
|
||||||
|
|
||||||
if port < 1024 {
|
if port < 1024 {
|
||||||
print_info("Note: Ports below 1024 may require root privileges");
|
print_info("Note: Ports below 1024 may require root privileges");
|
||||||
}
|
}
|
||||||
|
|
||||||
let host = optional_input("Host", Some("default: 0.0.0.0"))
|
let host =
|
||||||
.map_err(|e| e.to_string())?
|
optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||||
.unwrap_or_else(|| "0.0.0.0".to_string());
|
|
||||||
|
|
||||||
// Generate a webhook secret
|
// Generate a webhook secret
|
||||||
if confirm("Generate a webhook secret for authentication?", true).map_err(|e| e.to_string())? {
|
if confirm("Generate a webhook secret for authentication?", true)? {
|
||||||
let secret = generate_webhook_secret();
|
let secret = generate_webhook_secret();
|
||||||
secrets
|
secrets
|
||||||
.save_secret("http_webhook_secret", &SecretString::from(secret.clone()))
|
.save_secret("http_webhook_secret", &SecretString::from(secret))
|
||||||
.await?;
|
.await?;
|
||||||
print_success("Webhook secret generated and saved to database");
|
print_success("Webhook secret generated and saved to database");
|
||||||
print_info(&format!(
|
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
|
||||||
"Secret: {} (store this for your webhook clients)",
|
|
||||||
secret
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
||||||
@@ -497,11 +533,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Str
|
|||||||
|
|
||||||
/// Generate a random webhook secret.
|
/// Generate a random webhook secret.
|
||||||
pub fn generate_webhook_secret() -> String {
|
pub fn generate_webhook_secret() -> String {
|
||||||
use rand::RngCore;
|
generate_secret_with_length(32)
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let mut bytes = [0u8; 32];
|
|
||||||
rng.fill_bytes(&mut bytes);
|
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of WASM channel setup.
|
/// Result of WASM channel setup.
|
||||||
@@ -519,7 +551,7 @@ pub async fn setup_wasm_channel(
|
|||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
setup: &crate::channels::wasm::SetupSchema,
|
setup: &crate::channels::wasm::SetupSchema,
|
||||||
) -> Result<WasmChannelSetupResult, String> {
|
) -> Result<WasmChannelSetupResult, ChannelSetupError> {
|
||||||
println!("{} Setup:", channel_name);
|
println!("{} Setup:", channel_name);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
@@ -530,7 +562,7 @@ pub async fn setup_wasm_channel(
|
|||||||
"Existing {} found in database.",
|
"Existing {} found in database.",
|
||||||
secret_config.name
|
secret_config.name
|
||||||
));
|
));
|
||||||
if !confirm("Replace existing value?", false).map_err(|e| e.to_string())? {
|
if !confirm("Replace existing value?", false)? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,8 +570,7 @@ pub async fn setup_wasm_channel(
|
|||||||
// Get the value from user or auto-generate
|
// Get the value from user or auto-generate
|
||||||
let value = if secret_config.optional {
|
let value = if secret_config.optional {
|
||||||
let input_value =
|
let input_value =
|
||||||
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))
|
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))?;
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if let Some(v) = input_value {
|
if let Some(v) = input_value {
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
@@ -566,18 +597,21 @@ pub async fn setup_wasm_channel(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Required secret
|
// Required secret
|
||||||
let input_value = secret_input(&secret_config.prompt).map_err(|e| e.to_string())?;
|
let input_value = secret_input(&secret_config.prompt)?;
|
||||||
|
|
||||||
// Validate if pattern is provided
|
// Validate if pattern is provided
|
||||||
if let Some(ref pattern) = secret_config.validation {
|
if let Some(ref pattern) = secret_config.validation {
|
||||||
let re = regex::Regex::new(pattern)
|
let re = regex::Regex::new(pattern).map_err(|e| {
|
||||||
.map_err(|e| format!("Invalid validation pattern: {}", e))?;
|
ChannelSetupError::Validation(format!("Invalid validation pattern: {}", e))
|
||||||
|
})?;
|
||||||
if !re.is_match(input_value.expose_secret()) {
|
if !re.is_match(input_value.expose_secret()) {
|
||||||
print_error(&format!(
|
print_error(&format!(
|
||||||
"Value does not match expected format: {}",
|
"Value does not match expected format: {}",
|
||||||
pattern
|
pattern
|
||||||
));
|
));
|
||||||
return Err("Validation failed".to_string());
|
return Err(ChannelSetupError::Validation(
|
||||||
|
"Validation failed".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,14 +623,11 @@ pub async fn setup_wasm_channel(
|
|||||||
print_success(&format!("{} saved to database", secret_config.name));
|
print_success(&format!("{} saved to database", secret_config.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally validate the configuration
|
// TODO: Substitute secrets into the validation URL and make a
|
||||||
|
// GET request to verify the configured credentials actually work.
|
||||||
if let Some(ref validation_endpoint) = setup.validation_endpoint {
|
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!(
|
print_info(&format!(
|
||||||
"Validation endpoint configured: {} (validation skipped)",
|
"Validation endpoint configured: {} (validation not yet implemented)",
|
||||||
validation_endpoint
|
validation_endpoint
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -620,11 +651,23 @@ fn generate_secret_with_length(length: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use crate::setup::channels::generate_webhook_secret;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_webhook_secret() {
|
fn test_generate_webhook_secret() {
|
||||||
let secret = generate_webhook_secret();
|
let secret = generate_webhook_secret();
|
||||||
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
|
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_secret_with_length() {
|
||||||
|
use super::generate_secret_with_length;
|
||||||
|
|
||||||
|
let s = generate_secret_with_length(16);
|
||||||
|
assert_eq!(s.len(), 32); // 16 bytes = 32 hex chars
|
||||||
|
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
let s2 = generate_secret_with_length(1);
|
||||||
|
assert_eq!(s2.len(), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -3,7 +3,7 @@
|
|||||||
//! Provides a guided setup experience for:
|
//! Provides a guided setup experience for:
|
||||||
//! 1. Database connection
|
//! 1. Database connection
|
||||||
//! 2. Security (secrets master key)
|
//! 2. Security (secrets master key)
|
||||||
//! 3. NEAR AI authentication
|
//! 3. Inference provider selection
|
||||||
//! 4. Model selection
|
//! 4. Model selection
|
||||||
//! 5. Embeddings
|
//! 5. Embeddings
|
||||||
//! 6. Channel configuration (HTTP, Telegram, etc.)
|
//! 6. Channel configuration (HTTP, Telegram, etc.)
|
||||||
@@ -24,7 +24,8 @@ mod prompts;
|
|||||||
mod wizard;
|
mod wizard;
|
||||||
|
|
||||||
pub use channels::{
|
pub use channels::{
|
||||||
SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token,
|
ChannelSetupError, SecretsContext, setup_http, setup_telegram, setup_tunnel,
|
||||||
|
validate_telegram_token,
|
||||||
};
|
};
|
||||||
pub use prompts::{
|
pub use prompts::{
|
||||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use secrecy::SecretString;
|
|||||||
/// Display a numbered menu and get user selection.
|
/// Display a numbered menu and get user selection.
|
||||||
///
|
///
|
||||||
/// Returns the index (0-based) of the selected option.
|
/// Returns the index (0-based) of the selected option.
|
||||||
|
/// Pressing Enter without input selects the first option (index 0).
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
@@ -84,6 +85,10 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
|
|||||||
/// ])?;
|
/// ])?;
|
||||||
/// ```
|
/// ```
|
||||||
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
|
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
|
||||||
|
if options.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
|
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
|
||||||
let mut cursor_pos = 0;
|
let mut cursor_pos = 0;
|
||||||
|
|||||||
+863
-112
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,7 @@ fn parse_message(v: &serde_json::Value) -> Message {
|
|||||||
date: get_header(payload, "Date"),
|
date: get_header(payload, "Date"),
|
||||||
body: extract_body(payload),
|
body: extract_body(payload),
|
||||||
snippet: v["snippet"].as_str().unwrap_or("").to_string(),
|
snippet: v["snippet"].as_str().unwrap_or("").to_string(),
|
||||||
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
||||||
label_ids,
|
label_ids,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ pub fn list_messages(
|
|||||||
to: get_header(payload, "To"),
|
to: get_header(payload, "To"),
|
||||||
date: get_header(payload, "Date"),
|
date: get_header(payload, "Date"),
|
||||||
snippet: msg["snippet"].as_str().unwrap_or("").to_string(),
|
snippet: msg["snippet"].as_str().unwrap_or("").to_string(),
|
||||||
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
||||||
label_ids,
|
label_ids,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! # Capabilities Required
|
//! # Capabilities Required
|
||||||
//!
|
//!
|
||||||
//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE)
|
//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE)
|
||||||
//! - Secrets: `google_calendar_token` (OAuth 2.0 token, injected automatically)
|
//! - Secrets: `google_oauth_token` (OAuth 2.0 token, injected automatically)
|
||||||
//!
|
//!
|
||||||
//! # Supported Actions
|
//! # Supported Actions
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -269,8 +269,13 @@ pub fn replace_text(
|
|||||||
|
|
||||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||||
|
|
||||||
let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"]
|
let first_reply = parsed["replies"].as_array().and_then(|arr| arr.first());
|
||||||
.as_i64()
|
let occurrences = first_reply
|
||||||
|
.map(|r| {
|
||||||
|
r["replaceAllText"]["occurrencesChanged"]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(ReplaceResult {
|
Ok(ReplaceResult {
|
||||||
|
|||||||
@@ -330,7 +330,13 @@ pub fn add_sheet(spreadsheet_id: &str, title: &str) -> Result<AddSheetResult, St
|
|||||||
|
|
||||||
let parsed = batch_update(spreadsheet_id, requests)?;
|
let parsed = batch_update(spreadsheet_id, requests)?;
|
||||||
|
|
||||||
let reply = &parsed["replies"][0]["addSheet"]["properties"];
|
let reply = parsed["replies"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.map(|r| &r["addSheet"]["properties"]);
|
||||||
|
|
||||||
|
let reply = reply.ok_or_else(|| "No reply from batch update".to_string())?;
|
||||||
|
|
||||||
Ok(AddSheetResult {
|
Ok(AddSheetResult {
|
||||||
sheet: SheetInfo {
|
sheet: SheetInfo {
|
||||||
sheet_id: reply["sheetId"].as_i64().unwrap_or(0),
|
sheet_id: reply["sheetId"].as_i64().unwrap_or(0),
|
||||||
|
|||||||
Reference in New Issue
Block a user