mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +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:
@@ -75,6 +75,17 @@ HTTP_HOST=0.0.0.0
|
||||
HTTP_PORT=8080
|
||||
HTTP_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Signal Channel (optional, requires signal-cli daemon --http)
|
||||
# SIGNAL_HTTP_URL=http://127.0.0.1:8080
|
||||
# SIGNAL_ACCOUNT=+1234567890
|
||||
# SIGNAL_ALLOW_FROM=+1234567890,uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # comma-separated, * for all, empty = deny/require pairing
|
||||
# SIGNAL_ALLOW_FROM_GROUPS= # comma-separated group IDs, * for all, empty = deny all groups
|
||||
# SIGNAL_DM_POLICY=pairing # open | allowlist | pairing
|
||||
# SIGNAL_GROUP_POLICY=allowlist # allowlist | open | disabled
|
||||
# SIGNAL_GROUP_ALLOW_FROM= # comma-separated, empty = inherit from ALLOW_FROM
|
||||
# SIGNAL_IGNORE_ATTACHMENTS=false
|
||||
# SIGNAL_IGNORE_STORIES=true
|
||||
|
||||
# Agent Settings
|
||||
AGENT_NAME=ironclaw
|
||||
AGENT_MAX_PARALLEL_JOBS=5
|
||||
|
||||
Generated
+1
@@ -2728,6 +2728,7 @@ dependencies = [
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"libsql",
|
||||
"lru",
|
||||
"mime_guess",
|
||||
"open",
|
||||
"pgvector",
|
||||
|
||||
+2
-1
@@ -69,7 +69,7 @@ dotenvy = "0.15"
|
||||
toml = "0.8"
|
||||
|
||||
# Core types
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||
rust_decimal_macros = "1"
|
||||
@@ -149,6 +149,7 @@ bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
clap_complete = "4.5.0"
|
||||
lru = "0.16.3"
|
||||
|
||||
# HTML to Markdown conversion (feature gated)
|
||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
|
||||
+1
-2
@@ -68,7 +68,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
@@ -540,7 +540,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ Discord channel
|
||||
- ❌ Signal channel
|
||||
- ❌ Matrix channel
|
||||
- ❌ Other messaging platforms
|
||||
- ❌ TTS/audio features
|
||||
|
||||
@@ -31,6 +31,7 @@ mod channel;
|
||||
mod http;
|
||||
mod manager;
|
||||
mod repl;
|
||||
mod signal;
|
||||
pub mod wasm;
|
||||
pub mod web;
|
||||
mod webhook_server;
|
||||
@@ -39,5 +40,6 @@ pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, Sta
|
||||
pub use http::HttpChannel;
|
||||
pub use manager::ChannelManager;
|
||||
pub use repl::ReplChannel;
|
||||
pub use signal::SignalChannel;
|
||||
pub use web::GatewayChannel;
|
||||
pub use webhook_server::{WebhookServer, WebhookServerConfig};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ 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.
|
||||
@@ -43,6 +44,49 @@ pub struct GatewayConfig {
|
||||
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() {
|
||||
@@ -68,6 +112,58 @@ impl ChannelsConfig {
|
||||
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);
|
||||
@@ -78,6 +174,7 @@ impl ChannelsConfig {
|
||||
},
|
||||
http,
|
||||
gateway,
|
||||
signal,
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ use crate::settings::Settings;
|
||||
// Re-export all public types so `crate::config::FooConfig` continues to work.
|
||||
pub use self::agent::AgentConfig;
|
||||
pub use self::builder::BuilderModeConfig;
|
||||
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig};
|
||||
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
|
||||
pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
|
||||
@@ -103,7 +103,7 @@ impl ExtensionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
|
||||
scored.into_iter().map(|(r, _)| r).collect()
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -9,7 +9,7 @@ use ironclaw::{
|
||||
agent::{Agent, AgentDeps},
|
||||
app::{AppBuilder, AppBuilderFlags},
|
||||
channels::{
|
||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
|
||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
||||
WebhookServerConfig,
|
||||
wasm::{
|
||||
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
||||
@@ -368,6 +368,25 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Add Signal channel if configured and not CLI-only mode.
|
||||
if !cli.cli_only
|
||||
&& let Some(ref signal_config) = config.channels.signal
|
||||
{
|
||||
let signal_channel = SignalChannel::new(signal_config.clone())?;
|
||||
channel_names.push("signal".to_string());
|
||||
channels.add(Box::new(signal_channel)).await;
|
||||
let safe_url = SignalChannel::redact_url(&signal_config.http_url);
|
||||
tracing::info!(
|
||||
url = %safe_url,
|
||||
"Signal channel enabled"
|
||||
);
|
||||
if signal_config.allow_from.is_empty() {
|
||||
tracing::warn!(
|
||||
"Signal channel has empty allow_from list - ALL messages will be DENIED."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add HTTP channel if configured and not CLI-only mode.
|
||||
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
|
||||
if !cli.cli_only
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
|
||||
mod store;
|
||||
|
||||
pub use store::{PairingRequest, PairingStore, PairingStoreError};
|
||||
pub use store::{PairingRequest, PairingStore, PairingStoreError, UpsertResult};
|
||||
|
||||
@@ -225,7 +225,7 @@ impl Sanitizer {
|
||||
}
|
||||
|
||||
// Sort warnings by severity (critical first)
|
||||
warnings.sort_by(|a, b| b.severity.cmp(&a.severity));
|
||||
warnings.sort_by_key(|b| std::cmp::Reverse(b.severity));
|
||||
|
||||
// Determine if we need to modify content
|
||||
let has_critical = warnings.iter().any(|w| w.severity == Severity::Critical);
|
||||
|
||||
@@ -212,6 +212,41 @@ pub struct ChannelSettings {
|
||||
#[serde(default)]
|
||||
pub http_host: Option<String>,
|
||||
|
||||
/// Whether Signal channel is enabled.
|
||||
#[serde(default)]
|
||||
pub signal_enabled: bool,
|
||||
|
||||
/// Signal HTTP URL (signal-cli daemon endpoint).
|
||||
#[serde(default)]
|
||||
pub signal_http_url: Option<String>,
|
||||
|
||||
/// Signal account (E.164 phone number).
|
||||
#[serde(default)]
|
||||
pub signal_account: Option<String>,
|
||||
|
||||
/// Signal allow from list for DMs (comma-separated E.164 phone numbers).
|
||||
/// Comma-separated identifiers: E.164 phone numbers, `*`, bare UUIDs, or `uuid:<id>` entries.
|
||||
/// Defaults to the configured account.
|
||||
#[serde(default)]
|
||||
pub signal_allow_from: Option<String>,
|
||||
|
||||
/// Signal allow from groups (comma-separated group IDs).
|
||||
#[serde(default)]
|
||||
pub signal_allow_from_groups: Option<String>,
|
||||
|
||||
/// Signal DM policy: "open", "allowlist", or "pairing". Default: "pairing".
|
||||
#[serde(default)]
|
||||
pub signal_dm_policy: Option<String>,
|
||||
|
||||
/// Signal group policy: "allowlist", "open", or "disabled". Default: "allowlist".
|
||||
#[serde(default)]
|
||||
pub signal_group_policy: Option<String>,
|
||||
|
||||
/// Signal group allow from (comma-separated group member IDs).
|
||||
/// If empty, inherits from signal_allow_from.
|
||||
#[serde(default)]
|
||||
pub signal_group_allow_from: Option<String>,
|
||||
|
||||
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||
/// Captured during setup by having the user message the bot.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn prefilter_skills<'a>(
|
||||
.collect();
|
||||
|
||||
// Sort by score descending
|
||||
scored.sort_by(|a, b| b.score.cmp(&a.score));
|
||||
scored.sort_by_key(|b| std::cmp::Reverse(b.score));
|
||||
|
||||
// Apply candidate limit and context budget
|
||||
let mut result = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user