use std::collections::HashMap;
use std::path::PathBuf;
use secrecy::SecretString;
use serde::Deserialize;
use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
#[derive(Debug, Clone)]
pub struct ChannelsConfig {
pub cli: CliConfig,
pub http: Option<HttpConfig>,
pub gateway: Option<GatewayConfig>,
pub signal: Option<SignalConfig>,
pub wasm_channels_dir: std::path::PathBuf,
pub wasm_channels_enabled: bool,
pub wasm_channel_owner_ids: HashMap<String, i64>,
}
#[derive(Debug, Clone)]
pub struct CliConfig {
pub enabled: bool,
}
#[derive(Debug, Clone)]
pub struct HttpConfig {
pub host: String,
pub port: u16,
pub webhook_secret: Option<SecretString>,
pub user_id: String,
}
#[derive(Debug, Clone)]
pub struct GatewayConfig {
pub host: String,
pub port: u16,
pub auth_token: Option<String>,
pub user_id: String,
pub workspace_read_scopes: Vec<String>,
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserTokenConfig {
pub user_id: String,
#[serde(default)]
pub workspace_read_scopes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SignalConfig {
pub http_url: String,
pub account: String,
pub allow_from: Vec<String>,
pub allow_from_groups: Vec<String>,
pub dm_policy: String,
pub group_policy: String,
pub group_allow_from: Vec<String>,
pub ignore_attachments: bool,
pub ignore_stories: bool,
}
impl ChannelsConfig {
pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result<Self, ConfigError> {
let cs = &settings.channels;
let http_enabled_by_env =
optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some();
let http = if http_enabled_by_env || cs.http_enabled {
Some(HttpConfig {
host: optional_env("HTTP_HOST")?
.or_else(|| cs.http_host.clone())
.unwrap_or_else(|| "0.0.0.0".to_string()),
port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?,
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
user_id: owner_id.to_string(),
})
} else {
None
};
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| owner_id.to_string());
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
};
for layer in &memory_layers {
if layer.name.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: "layer name must not be empty".to_string(),
});
}
if layer.name.len() > 64 {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer name '{}' exceeds 64 characters", layer.name),
});
}
if !layer
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!(
"layer name '{}' contains invalid characters \
(allowed: a-z, A-Z, 0-9, _, -)",
layer.name
),
});
}
if layer.scope.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer '{}' has an empty scope", layer.name),
});
}
}
{
let mut seen = std::collections::HashSet::new();
for layer in &memory_layers {
if !seen.insert(&layer.name) {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("duplicate layer name '{}'", layer.name),
});
}
}
}
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
match optional_env("GATEWAY_USER_TOKENS")? {
Some(json_str) => {
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
&json_str,
)
.map_err(|e| ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"must be valid JSON object mapping tokens to user configs: {e}"
),
})?;
if tokens.is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message:
"token map is empty — remove the variable to use single-user mode"
.to_string(),
});
}
for (tok, cfg) in &tokens {
if cfg.user_id.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"token '{}...' has an empty user_id",
&tok[..tok.len().min(8)]
),
});
}
}
Some(tokens)
}
None => None,
};
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
for scope in &workspace_read_scopes {
if scope.len() > 128 {
return Err(ConfigError::InvalidValue {
key: "WORKSPACE_READ_SCOPES".to_string(),
message: format!("scope '{}...' exceeds 128 characters", &scope[..32]),
});
}
}
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?
.or_else(|| cs.gateway_host.clone())
.unwrap_or_else(|| "127.0.0.1".to_string()),
port: parse_optional_env(
"GATEWAY_PORT",
cs.gateway_port.unwrap_or(DEFAULT_GATEWAY_PORT),
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id,
workspace_read_scopes,
memory_layers,
user_tokens,
})
} else {
None
};
let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone());
let signal = if let Some(http_url) = signal_url {
let account = optional_env("SIGNAL_ACCOUNT")?
.or_else(|| cs.signal_account.clone())
.ok_or(ConfigError::InvalidValue {
key: "SIGNAL_ACCOUNT".to_string(),
message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(),
})?;
let allow_from =
match optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()) {
None => vec![account.clone()],
Some(s) => s
.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
};
let dm_policy = optional_env("SIGNAL_DM_POLICY")?
.or_else(|| cs.signal_dm_policy.clone())
.unwrap_or_else(|| "pairing".to_string());
let group_policy = optional_env("SIGNAL_GROUP_POLICY")?
.or_else(|| cs.signal_group_policy.clone())
.unwrap_or_else(|| "allowlist".to_string());
Some(SignalConfig {
http_url,
account,
allow_from,
allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")?
.or_else(|| cs.signal_allow_from_groups.clone())
.map(|s| {
s.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default(),
dm_policy,
group_policy,
group_allow_from: optional_env("SIGNAL_GROUP_ALLOW_FROM")?
.or_else(|| cs.signal_group_allow_from.clone())
.map(|s| {
s.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default(),
ignore_attachments: optional_env("SIGNAL_IGNORE_ATTACHMENTS")?
.map(|s| s.to_lowercase() == "true" || s == "1")
.unwrap_or(false),
ignore_stories: optional_env("SIGNAL_IGNORE_STORIES")?
.map(|s| s.to_lowercase() == "true" || s == "1")
.unwrap_or(true),
})
} else {
None
};
let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?;
Ok(Self {
cli: CliConfig {
enabled: cli_enabled,
},
http,
gateway,
signal,
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
.map(PathBuf::from)
.or_else(|| cs.wasm_channels_dir.clone())
.unwrap_or_else(default_channels_dir),
wasm_channels_enabled: parse_bool_env(
"WASM_CHANNELS_ENABLED",
cs.wasm_channels_enabled,
)?,
wasm_channel_owner_ids: {
let mut ids = cs.wasm_channel_owner_ids.clone();
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
ConfigError::InvalidValue {
key: "TELEGRAM_OWNER_ID".to_string(),
message: format!("must be an integer: {e}"),
}
})?;
ids.insert("telegram".to_string(), id);
}
ids
},
})
}
}
pub const DEFAULT_GATEWAY_PORT: u16 = 3000;
fn default_channels_dir() -> PathBuf {
ironclaw_base_dir().join("channels")
}
#[cfg(test)]
mod tests {
use crate::config::channels::*;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
#[test]
fn cli_config_fields() {
let cfg = CliConfig { enabled: true };
assert!(cfg.enabled);
let disabled = CliConfig { enabled: false };
assert!(!disabled.enabled);
}
#[test]
fn http_config_fields() {
let cfg = HttpConfig {
host: "0.0.0.0".to_string(),
port: 8080,
webhook_secret: None,
user_id: "http".to_string(),
};
assert_eq!(cfg.host, "0.0.0.0");
assert_eq!(cfg.port, 8080);
assert!(cfg.webhook_secret.is_none());
assert_eq!(cfg.user_id, "http");
}
#[test]
fn http_config_with_secret() {
let cfg = HttpConfig {
host: "127.0.0.1".to_string(),
port: 9090,
webhook_secret: Some(secrecy::SecretString::from("s3cret".to_string())),
user_id: "webhook-bot".to_string(),
};
assert!(cfg.webhook_secret.is_some());
assert_eq!(cfg.port, 9090);
}
#[test]
fn gateway_config_fields() {
let cfg = GatewayConfig {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
}
#[test]
fn gateway_config_no_auth_token() {
let cfg = GatewayConfig {
host: "0.0.0.0".to_string(),
port: 3001,
auth_token: None,
user_id: "anon".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert!(cfg.auth_token.is_none());
}
#[test]
fn signal_config_fields_and_defaults() {
let cfg = SignalConfig {
http_url: "http://127.0.0.1:8080".to_string(),
account: "+1234567890".to_string(),
allow_from: vec!["+1234567890".to_string()],
allow_from_groups: vec![],
dm_policy: "pairing".to_string(),
group_policy: "allowlist".to_string(),
group_allow_from: vec![],
ignore_attachments: false,
ignore_stories: true,
};
assert_eq!(cfg.http_url, "http://127.0.0.1:8080");
assert_eq!(cfg.account, "+1234567890");
assert_eq!(cfg.allow_from, vec!["+1234567890"]);
assert!(cfg.allow_from_groups.is_empty());
assert_eq!(cfg.dm_policy, "pairing");
assert_eq!(cfg.group_policy, "allowlist");
assert!(cfg.group_allow_from.is_empty());
assert!(!cfg.ignore_attachments);
assert!(cfg.ignore_stories);
}
#[test]
fn signal_config_open_policies() {
let cfg = SignalConfig {
http_url: "http://localhost:7583".to_string(),
account: "+0000000000".to_string(),
allow_from: vec!["*".to_string()],
allow_from_groups: vec!["*".to_string()],
dm_policy: "open".to_string(),
group_policy: "open".to_string(),
group_allow_from: vec![],
ignore_attachments: true,
ignore_stories: false,
};
assert_eq!(cfg.allow_from, vec!["*"]);
assert_eq!(cfg.allow_from_groups, vec!["*"]);
assert_eq!(cfg.dm_policy, "open");
assert_eq!(cfg.group_policy, "open");
assert!(cfg.ignore_attachments);
assert!(!cfg.ignore_stories);
}
#[test]
fn channels_config_fields() {
let cfg = ChannelsConfig {
cli: CliConfig { enabled: true },
http: None,
gateway: None,
signal: None,
wasm_channels_dir: PathBuf::from("/tmp/channels"),
wasm_channels_enabled: true,
wasm_channel_owner_ids: HashMap::new(),
};
assert!(cfg.cli.enabled);
assert!(cfg.http.is_none());
assert!(cfg.gateway.is_none());
assert!(cfg.signal.is_none());
assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels"));
assert!(cfg.wasm_channels_enabled);
assert!(cfg.wasm_channel_owner_ids.is_empty());
}
#[test]
fn channels_config_with_owner_ids() {
let mut ids = HashMap::new();
ids.insert("telegram".to_string(), 12345_i64);
ids.insert("slack".to_string(), 67890_i64);
let cfg = ChannelsConfig {
cli: CliConfig { enabled: false },
http: None,
gateway: None,
signal: None,
wasm_channels_dir: PathBuf::from("/opt/channels"),
wasm_channels_enabled: false,
wasm_channel_owner_ids: ids,
};
assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345));
assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890));
assert!(!cfg.wasm_channels_enabled);
}
#[test]
fn default_channels_dir_ends_with_channels() {
let dir = default_channels_dir();
assert!(
dir.ends_with("channels"),
"expected path ending in 'channels', got: {dir:?}"
);
}
#[test]
fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
let _guard = lock_env();
let mut settings = Settings::default();
settings.channels.http_enabled = true;
settings.channels.http_host = Some("127.0.0.2".to_string());
settings.channels.http_port = Some(8181);
settings.channels.gateway_enabled = true;
settings.channels.gateway_host = Some("127.0.0.3".to_string());
settings.channels.gateway_port = Some(9191);
settings.channels.gateway_auth_token = Some("tok".to_string());
settings.channels.signal_http_url = Some("http://127.0.0.1:8080".to_string());
settings.channels.signal_account = Some("+15551234567".to_string());
settings.channels.signal_allow_from = Some("+15551234567,+15557654321".to_string());
settings.channels.wasm_channels_dir = Some(PathBuf::from("/tmp/settings-channels"));
settings.channels.wasm_channels_enabled = false;
let cfg = ChannelsConfig::resolve(&settings, "owner-scope").expect("resolve");
let http = cfg.http.expect("http config");
assert_eq!(http.host, "127.0.0.2");
assert_eq!(http.port, 8181);
assert_eq!(http.user_id, "owner-scope");
let gateway = cfg.gateway.expect("gateway config");
assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567");
assert_eq!(signal.allow_from, vec!["+15551234567", "+15557654321"]);
assert_eq!(
cfg.wasm_channels_dir,
PathBuf::from("/tmp/settings-channels")
);
assert!(!cfg.wasm_channels_enabled);
}
}