mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
feat(channels): add native Signal channel via signal-cli HTTP daemon (#271)
* 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
This commit is contained in:
@@ -11,6 +11,8 @@ use std::sync::Arc;
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::secrets::SecretsCrypto;
|
||||
@@ -639,6 +641,19 @@ pub struct HttpSetupResult {
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
/// Result of Signal channel setup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalSetupResult {
|
||||
pub enabled: bool,
|
||||
pub http_url: String,
|
||||
pub account: String,
|
||||
pub allow_from: String,
|
||||
pub allow_from_groups: String,
|
||||
pub dm_policy: String,
|
||||
pub group_policy: String,
|
||||
pub group_allow_from: String,
|
||||
}
|
||||
|
||||
/// Set up HTTP webhook channel.
|
||||
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, ChannelSetupError> {
|
||||
println!("HTTP Webhook Setup:");
|
||||
@@ -684,6 +699,188 @@ pub fn generate_webhook_secret() -> String {
|
||||
generate_secret_with_length(32)
|
||||
}
|
||||
|
||||
fn validate_e164(account: &str) -> Result<(), String> {
|
||||
if !account.starts_with('+') {
|
||||
return Err("E.164 account must start with '+'".to_string());
|
||||
}
|
||||
let digits = &account[1..];
|
||||
if digits.is_empty() {
|
||||
return Err("E.164 account must have digits after '+'".to_string());
|
||||
}
|
||||
if !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("E.164 account must contain only digits after '+'".to_string());
|
||||
}
|
||||
if digits.len() < 7 || digits.len() > 15 {
|
||||
return Err("E.164 account must be 7-15 digits after '+'".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_allow_from_list(list: &str) -> Result<(), String> {
|
||||
if list.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for (i, item) in list.split(',').enumerate() {
|
||||
let trimmed = item.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "*" {
|
||||
continue;
|
||||
}
|
||||
if let Some(uuid_part) = trimmed.strip_prefix("uuid:") {
|
||||
if Uuid::parse_str(uuid_part).is_err() {
|
||||
return Err(format!(
|
||||
"allow_from[{}]: '{}' is not a valid UUID (after 'uuid:' prefix)",
|
||||
i, trimmed
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if validate_e164(trimmed).is_ok() {
|
||||
continue;
|
||||
}
|
||||
if Uuid::parse_str(trimmed).is_ok() {
|
||||
continue;
|
||||
}
|
||||
return Err(format!(
|
||||
"allow_from[{}]: '{}' must be '*', E.164 phone number, UUID, or 'uuid:<id>'",
|
||||
i, trimmed
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_allow_from_groups_list(list: &str) -> Result<(), String> {
|
||||
if list.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for (i, item) in list.split(',').enumerate() {
|
||||
let trimmed = item.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "*" {
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!(
|
||||
"allow_from_groups[{}]: group ID cannot be empty",
|
||||
i
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up Signal channel.
|
||||
/// `Settings` is reserved for future use
|
||||
pub async fn setup_signal(_settings: &Settings) -> Result<SignalSetupResult, ChannelSetupError> {
|
||||
println!("Signal Channel Setup:");
|
||||
println!();
|
||||
print_info("Signal channel connects to a signal-cli daemon running in HTTP mode.");
|
||||
println!();
|
||||
|
||||
let http_url = input("Signal-cli HTTP URL")?;
|
||||
match Url::parse(&http_url) {
|
||||
Ok(url) if url.scheme() == "http" || url.scheme() == "https" => {}
|
||||
Ok(_) => {
|
||||
print_error("URL must use http or https scheme");
|
||||
return Err(ChannelSetupError::Validation(
|
||||
"Invalid HTTP URL: must use http or https scheme".to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(&format!("Invalid URL: {}", e));
|
||||
return Err(ChannelSetupError::Validation(format!(
|
||||
"Invalid HTTP URL: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let account = input("Signal account (E.164)")?;
|
||||
if let Err(e) = validate_e164(&account) {
|
||||
print_error(&e);
|
||||
return Err(ChannelSetupError::Validation(e));
|
||||
}
|
||||
|
||||
let allow_from = optional_input(
|
||||
"Allow from (comma-separated: E.164 numbers, '*' for anyone, UUIDs or 'uuid:<id>'; empty for self-only)",
|
||||
Some(&format!("default: {} (self-only)", account)),
|
||||
)?
|
||||
.unwrap_or_else(|| account.clone());
|
||||
|
||||
let dm_policy = optional_input(
|
||||
"DM policy (open, allowlist, pairing)",
|
||||
Some("default: pairing"),
|
||||
)?
|
||||
.unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
let allow_from_groups = optional_input(
|
||||
"Allow from groups (comma-separated group IDs, '*' for any group; empty for none)",
|
||||
Some("default: (none)"),
|
||||
)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let group_policy = optional_input(
|
||||
"Group policy (allowlist, open, disabled)",
|
||||
Some("default: allowlist"),
|
||||
)?
|
||||
.unwrap_or_else(|| "allowlist".to_string());
|
||||
|
||||
let group_allow_from = optional_input(
|
||||
"Group allow from (comma-separated member IDs; empty to inherit from allow_from)",
|
||||
Some("default: (inherit from allow_from)"),
|
||||
)?
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Err(e) = validate_allow_from_list(&allow_from) {
|
||||
print_error(&e);
|
||||
return Err(ChannelSetupError::Validation(e));
|
||||
}
|
||||
|
||||
if let Err(e) = validate_allow_from_groups_list(&allow_from_groups) {
|
||||
print_error(&e);
|
||||
return Err(ChannelSetupError::Validation(e));
|
||||
}
|
||||
|
||||
println!();
|
||||
print_success(&format!(
|
||||
"Signal channel configured for account: {}",
|
||||
account
|
||||
));
|
||||
print_info(&format!("HTTP URL: {}", http_url));
|
||||
if allow_from == account {
|
||||
print_info("Allow from: self-only");
|
||||
} else {
|
||||
print_info(&format!("Allow from: {}", allow_from));
|
||||
}
|
||||
print_info(&format!("DM policy: {}", dm_policy));
|
||||
if allow_from_groups.is_empty() {
|
||||
print_info("Allow from groups: (none)");
|
||||
} else {
|
||||
print_info(&format!("Allow from groups: {}", allow_from_groups));
|
||||
}
|
||||
print_info(&format!("Group policy: {}", group_policy));
|
||||
if group_allow_from.is_empty() {
|
||||
print_info("Group allow from: (inherits from allow_from)");
|
||||
} else {
|
||||
print_info(&format!("Group allow from: {}", group_allow_from));
|
||||
}
|
||||
|
||||
Ok(SignalSetupResult {
|
||||
enabled: true,
|
||||
http_url,
|
||||
account,
|
||||
allow_from,
|
||||
allow_from_groups,
|
||||
dm_policy,
|
||||
group_policy,
|
||||
group_allow_from,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of WASM channel setup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmChannelSetupResult {
|
||||
|
||||
+64
-5
@@ -27,13 +27,18 @@ use crate::llm::{SessionConfig, SessionManager};
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::settings::{KeySource, Settings};
|
||||
use crate::setup::channels::{
|
||||
SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel,
|
||||
SecretsContext, setup_http, setup_signal, setup_telegram, setup_tunnel, setup_wasm_channel,
|
||||
};
|
||||
use crate::setup::prompts::{
|
||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||
print_success, secret_input, select_many, select_one,
|
||||
};
|
||||
|
||||
// unused const, keep commented for clarity / future use
|
||||
// const CHANNEL_INDEX_CLI: usize = 0;
|
||||
const CHANNEL_INDEX_HTTP: usize = 1;
|
||||
const CHANNEL_INDEX_SIGNAL: usize = 2;
|
||||
|
||||
/// Setup wizard error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SetupError {
|
||||
@@ -1443,8 +1448,11 @@ impl SetupWizard {
|
||||
"HTTP webhook".to_string(),
|
||||
self.settings.channels.http_enabled,
|
||||
),
|
||||
("Signal".to_string(), self.settings.channels.signal_enabled),
|
||||
];
|
||||
|
||||
let non_wasm_count = options.len();
|
||||
|
||||
// Add available WASM channels (installed + bundled + registry)
|
||||
for name in &wasm_channel_names {
|
||||
let is_enabled = self.settings.channels.wasm_channels.contains(name);
|
||||
@@ -1466,7 +1474,7 @@ impl SetupWizard {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if selected.contains(&(idx + 2)) {
|
||||
if selected.contains(&(non_wasm_count + idx)) {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
@@ -1514,7 +1522,8 @@ impl SetupWizard {
|
||||
}
|
||||
|
||||
// Determine if we need secrets context
|
||||
let needs_secrets = selected.contains(&1) || !selected_wasm_channels.is_empty();
|
||||
let needs_secrets =
|
||||
selected.contains(&CHANNEL_INDEX_HTTP) || !selected_wasm_channels.is_empty();
|
||||
let secrets = if needs_secrets {
|
||||
match self.init_secrets_context().await {
|
||||
Ok(ctx) => Some(ctx),
|
||||
@@ -1528,8 +1537,8 @@ impl SetupWizard {
|
||||
None
|
||||
};
|
||||
|
||||
// HTTP is index 1
|
||||
if selected.contains(&1) {
|
||||
// HTTP channel
|
||||
if selected.contains(&CHANNEL_INDEX_HTTP) {
|
||||
println!();
|
||||
if let Some(ref ctx) = secrets {
|
||||
let result = setup_http(ctx).await?;
|
||||
@@ -1544,6 +1553,29 @@ impl SetupWizard {
|
||||
self.settings.channels.http_enabled = false;
|
||||
}
|
||||
|
||||
// Signal channel
|
||||
if selected.contains(&CHANNEL_INDEX_SIGNAL) {
|
||||
println!();
|
||||
let result = setup_signal(&self.settings).await?;
|
||||
self.settings.channels.signal_enabled = result.enabled;
|
||||
self.settings.channels.signal_http_url = Some(result.http_url);
|
||||
self.settings.channels.signal_account = Some(result.account);
|
||||
self.settings.channels.signal_allow_from = Some(result.allow_from);
|
||||
self.settings.channels.signal_allow_from_groups = Some(result.allow_from_groups);
|
||||
self.settings.channels.signal_dm_policy = Some(result.dm_policy);
|
||||
self.settings.channels.signal_group_policy = Some(result.group_policy);
|
||||
self.settings.channels.signal_group_allow_from = Some(result.group_allow_from);
|
||||
} else {
|
||||
self.settings.channels.signal_enabled = false;
|
||||
self.settings.channels.signal_http_url = None;
|
||||
self.settings.channels.signal_account = None;
|
||||
self.settings.channels.signal_allow_from = None;
|
||||
self.settings.channels.signal_allow_from_groups = None;
|
||||
self.settings.channels.signal_dm_policy = None;
|
||||
self.settings.channels.signal_group_policy = None;
|
||||
self.settings.channels.signal_group_allow_from = None;
|
||||
}
|
||||
|
||||
let discovered_by_name: HashMap<String, ChannelCapabilitiesFile> =
|
||||
discovered_channels.into_iter().collect();
|
||||
|
||||
@@ -1939,6 +1971,33 @@ impl SetupWizard {
|
||||
env_vars.push(("ONBOARD_COMPLETED", "true".to_string()));
|
||||
}
|
||||
|
||||
// Signal channel env vars (chicken-and-egg: config resolves before DB).
|
||||
if let Some(ref url) = self.settings.channels.signal_http_url {
|
||||
env_vars.push(("SIGNAL_HTTP_URL", url.clone()));
|
||||
}
|
||||
if let Some(ref account) = self.settings.channels.signal_account {
|
||||
env_vars.push(("SIGNAL_ACCOUNT", account.clone()));
|
||||
}
|
||||
if let Some(ref allow_from) = self.settings.channels.signal_allow_from {
|
||||
env_vars.push(("SIGNAL_ALLOW_FROM", allow_from.clone()));
|
||||
}
|
||||
if let Some(ref allow_from_groups) = self.settings.channels.signal_allow_from_groups
|
||||
&& !allow_from_groups.is_empty()
|
||||
{
|
||||
env_vars.push(("SIGNAL_ALLOW_FROM_GROUPS", allow_from_groups.clone()));
|
||||
}
|
||||
if let Some(ref dm_policy) = self.settings.channels.signal_dm_policy {
|
||||
env_vars.push(("SIGNAL_DM_POLICY", dm_policy.clone()));
|
||||
}
|
||||
if let Some(ref group_policy) = self.settings.channels.signal_group_policy {
|
||||
env_vars.push(("SIGNAL_GROUP_POLICY", group_policy.clone()));
|
||||
}
|
||||
if let Some(ref group_allow_from) = self.settings.channels.signal_group_allow_from
|
||||
&& !group_allow_from.is_empty()
|
||||
{
|
||||
env_vars.push(("SIGNAL_GROUP_ALLOW_FROM", group_allow_from.clone()));
|
||||
}
|
||||
|
||||
if !env_vars.is_empty() {
|
||||
let pairs: Vec<(&str, &str)> = env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| {
|
||||
|
||||
Reference in New Issue
Block a user