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
+11
View File
@@ -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
View File
@@ -2728,6 +2728,7 @@ dependencies = [
"hyper 1.8.1",
"hyper-util",
"libsql",
"lru",
"mime_guess",
"open",
"pgvector",
+2 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+97
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -7,4 +7,4 @@
mod store;
pub use store::{PairingRequest, PairingStore, PairingStoreError};
pub use store::{PairingRequest, PairingStore, PairingStoreError, UpsertResult};
+1 -1
View File
@@ -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);
+35
View File
@@ -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)]
+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| {
+1 -1
View File
@@ -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();