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:
ibhagwan
2026-02-24 10:25:16 +04:00
committed by GitHub
parent 3e552e0e8e
commit b0b3a50fa3
16 changed files with 2655 additions and 14 deletions
+197
View File
@@ -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
View File
@@ -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| {