mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-03 01:59:23 +00:00
* feat(channels): add native Signal channel via signal-cli HTTP daemon Implement a native Rust Signal channel that connects to a running signal-cli daemon's HTTP endpoint, enabling Signal messaging without WASM overhead. Architecture: - SSE listener at /api/v1/events for receiving messages with automatic reconnection and exponential backoff - JSON-RPC client at /api/v1/rpc for sending messages and typing indicators - Reply target tracking via Arc<RwLock<HashMap>> to route responses back to the correct DM or group conversation Features: - User allowlisting supporting E.164 phone numbers, bare UUIDs, and uuid:-prefixed identifiers (matching OpenClaw's format) - Group allowlisting with wildcard (*) support - Configurable story and attachment-only message filtering - Health check via signal-cli /api/v1/check - Broadcast support to all tracked reply targets Configuration via environment variables: - SIGNAL_HTTP_URL, SIGNAL_ACCOUNT (required) - SIGNAL_ALLOWED_USERS, SIGNAL_ALLOWED_GROUPS - SIGNAL_IGNORE_ATTACHMENTS (default: false) - SIGNAL_IGNORE_STORIES (default: true) Includes unit tests covering allowlist logic, envelope parsing, recipient targeting, SSE deserialization, and edge cases. * refactor(signal): remove expect|unwrap calls - Change SignalChannel::new to return Result<Self, ChannelError> - Replace .expect() on reqwest client build with proper error handling - Replace .expect() on NonZeroUsize with compile-time const using unsafe new_unchecked - Propagate errors through test helpers to avoid unwraps in tests * fix(signal): prevent OOM from chunked response without Content-Length Use bytes_stream() to check response size during download rather than buffering entire body first. This closes the OOM vector where a malicious signal-cli daemon could send unbounded chunked data. * fix(signal): align is_e164 minimum digits with setup wizard Both now require 7-15 digits after '+', preventing environment variable bypass of the stricter onboarding validation. * refactor(signal): extract from_parts constructor Extract SignalChannel::from_parts() used by both new() and sse_listener() to ensure consistent object construction. * chore: remove redundant unused var * refactor(signal): rename allowed_users to allow_from and add dm_policy/group_policy - Rename allowed_users -> allow_from for consistency with other channels - Rename allowed_groups -> allow_from_groups - Add dm_policy field: 'open', 'allowlist', or 'pairing' (default: 'pairing') - Add group_policy field: 'allowlist', 'open', or 'disabled' (default: 'allowlist') - Add group_allow_from field that inherits from allow_from if empty - Implement dm_policy and group_policy logic in message processing - Add environment variable resolution: SIGNAL_ALLOW_FROM, SIGNAL_ALLOW_FROM_GROUPS, SIGNAL_DM_POLICY, SIGNAL_GROUP_POLICY, SIGNAL_GROUP_ALLOW_FROM - Add setup wizard prompts for new policy options - Note: full pairing flow (PairingStore integration) marked as pending for future PR * feat(signal): implement DM pairing workflow for unapproved senders - Add PairingStore integration to check approved senders - Handle pairing requests for unknown senders with dm_policy=pairing - Send pairing reply message with approval instructions - Update FEATURE_PARITY.md to reflect DM pairing support * chore(ci): fix clippy warnings
201 lines
7.7 KiB
Rust
201 lines
7.7 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use secrecy::SecretString;
|
|
|
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
/// Channel configurations.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChannelsConfig {
|
|
pub cli: CliConfig,
|
|
pub http: Option<HttpConfig>,
|
|
pub gateway: Option<GatewayConfig>,
|
|
pub signal: Option<SignalConfig>,
|
|
/// Directory containing WASM channel modules (default: ~/.ironclaw/channels/).
|
|
pub wasm_channels_dir: std::path::PathBuf,
|
|
/// Whether WASM channels are enabled.
|
|
pub wasm_channels_enabled: bool,
|
|
/// Telegram owner user ID. When set, the bot only responds to this user.
|
|
pub telegram_owner_id: Option<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,
|
|
}
|
|
|
|
/// Web gateway configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GatewayConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
/// Bearer token for authentication. Random hex generated at startup if unset.
|
|
pub auth_token: Option<String>,
|
|
pub user_id: String,
|
|
}
|
|
|
|
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SignalConfig {
|
|
/// Base URL of the signal-cli daemon HTTP endpoint (e.g. `http://127.0.0.1:8080`).
|
|
pub http_url: String,
|
|
/// Signal account identifier (E.164 phone number, e.g. `+1234567890`).
|
|
pub account: String,
|
|
/// Users allowed to interact with the bot in DMs.
|
|
///
|
|
/// Each entry is one of:
|
|
/// - `*` — allow everyone
|
|
/// - E.164 phone number (e.g. `+1234567890`)
|
|
/// - bare UUID (e.g. `a1b2c3d4-e5f6-7890-abcd-ef1234567890`)
|
|
/// - `uuid:<id>` prefix form (e.g. `uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890`)
|
|
///
|
|
/// An empty list denies all senders (secure by default).
|
|
pub allow_from: Vec<String>,
|
|
/// Groups allowed to interact with the bot.
|
|
///
|
|
/// - Empty list — deny all group messages (DMs only, secure by default).
|
|
/// - `*` — allow all groups.
|
|
/// - Specific group IDs — allow only those groups.
|
|
pub allow_from_groups: Vec<String>,
|
|
/// DM policy: "open", "allowlist", or "pairing". Default: "pairing".
|
|
///
|
|
/// - "open" — allow all DM senders (ignores allow_from for DMs)
|
|
/// - "allowlist" — only allow senders in allow_from list
|
|
/// - "pairing" — allowlist + send pairing reply to unknown users
|
|
pub dm_policy: String,
|
|
/// Group policy: "allowlist", "open", or "disabled". Default: "allowlist".
|
|
///
|
|
/// - "disabled" — deny all group messages
|
|
/// - "allowlist" — check allow_from_groups and group_allow_from
|
|
/// - "open" — accept all group messages (respects allow_from_groups for group ID)
|
|
pub group_policy: String,
|
|
/// Allow list for group message senders. If empty, inherits from allow_from.
|
|
pub group_allow_from: Vec<String>,
|
|
/// Skip messages that contain only attachments (no text).
|
|
pub ignore_attachments: bool,
|
|
/// Skip story messages.
|
|
pub ignore_stories: bool,
|
|
}
|
|
|
|
impl ChannelsConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
|
|
Some(HttpConfig {
|
|
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
|
|
port: parse_optional_env("HTTP_PORT", 8080)?,
|
|
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
|
|
user_id: optional_env("HTTP_USER_ID")?.unwrap_or_else(|| "http".to_string()),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", true)?;
|
|
let gateway = if gateway_enabled {
|
|
Some(GatewayConfig {
|
|
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
|
|
port: parse_optional_env("GATEWAY_PORT", 3000)?,
|
|
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?,
|
|
user_id: optional_env("GATEWAY_USER_ID")?.unwrap_or_else(|| "default".to_string()),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let signal = if let Some(http_url) = optional_env("SIGNAL_HTTP_URL")? {
|
|
let account = optional_env("SIGNAL_ACCOUNT")?.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 std::env::var_os("SIGNAL_ALLOW_FROM") {
|
|
None => vec![account.clone()],
|
|
Some(val) => {
|
|
let s = val.to_string_lossy();
|
|
s.split(',')
|
|
.map(|e| e.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|
|
};
|
|
let dm_policy =
|
|
optional_env("SIGNAL_DM_POLICY")?.unwrap_or_else(|| "pairing".to_string());
|
|
let group_policy =
|
|
optional_env("SIGNAL_GROUP_POLICY")?.unwrap_or_else(|| "allowlist".to_string());
|
|
Some(SignalConfig {
|
|
http_url,
|
|
account,
|
|
allow_from,
|
|
allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")?
|
|
.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")?
|
|
.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 = optional_env("CLI_ENABLED")?
|
|
.map(|s| s.to_lowercase() != "false" && s != "0")
|
|
.unwrap_or(true);
|
|
|
|
Ok(Self {
|
|
cli: CliConfig {
|
|
enabled: cli_enabled,
|
|
},
|
|
http,
|
|
gateway,
|
|
signal,
|
|
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(default_channels_dir),
|
|
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
|
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
|
.map(|s| s.parse())
|
|
.transpose()
|
|
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
|
key: "TELEGRAM_OWNER_ID".to_string(),
|
|
message: format!("must be an integer: {e}"),
|
|
})?
|
|
.or(settings.channels.telegram_owner_id),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Get the default channels directory (~/.ironclaw/channels/).
|
|
fn default_channels_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".ironclaw")
|
|
.join("channels")
|
|
}
|