diff --git a/src/channels/mod.rs b/src/channels/mod.rs index d63f63d..a214d0c 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -6,6 +6,7 @@ pub mod imessage; pub mod irc; pub mod lark; pub mod matrix; +pub mod signal; pub mod slack; pub mod telegram; pub mod traits; @@ -19,6 +20,7 @@ pub use imessage::IMessageChannel; pub use irc::IrcChannel; pub use lark::LarkChannel; pub use matrix::MatrixChannel; +pub use signal::SignalChannel; pub use slack::SlackChannel; pub use telegram::TelegramChannel; pub use traits::Channel; @@ -579,6 +581,7 @@ pub fn handle_command(command: crate::ChannelCommands, config: &Config) -> Resul ("Webhook", config.channels_config.webhook.is_some()), ("iMessage", config.channels_config.imessage.is_some()), ("Matrix", config.channels_config.matrix.is_some()), + ("Signal", config.channels_config.signal.is_some()), ("WhatsApp", config.channels_config.whatsapp.is_some()), ("Email", config.channels_config.email.is_some()), ("IRC", config.channels_config.irc.is_some()), @@ -680,6 +683,20 @@ pub async fn doctor_channels(config: Config) -> Result<()> { )); } + if let Some(ref sig) = config.channels_config.signal { + channels.push(( + "Signal", + Arc::new(SignalChannel::new( + sig.http_url.clone(), + sig.account.clone(), + sig.group_id.clone(), + sig.allowed_from.clone(), + sig.ignore_attachments, + sig.ignore_stories, + )), + )); + } + if let Some(ref wa) = config.channels_config.whatsapp { channels.push(( "WhatsApp", @@ -957,6 +974,17 @@ pub async fn start_channels(config: Config) -> Result<()> { ))); } + if let Some(ref sig) = config.channels_config.signal { + channels.push(Arc::new(SignalChannel::new( + sig.http_url.clone(), + sig.account.clone(), + sig.group_id.clone(), + sig.allowed_from.clone(), + sig.ignore_attachments, + sig.ignore_stories, + ))); + } + if let Some(ref wa) = config.channels_config.whatsapp { channels.push(Arc::new(WhatsAppChannel::new( wa.access_token.clone(), diff --git a/src/channels/signal.rs b/src/channels/signal.rs new file mode 100644 index 0000000..62e958e --- /dev/null +++ b/src/channels/signal.rs @@ -0,0 +1,744 @@ +use crate::channels::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::Client; +use serde::Deserialize; +use tokio::sync::mpsc; +use uuid::Uuid; + +/// Signal channel using signal-cli daemon's native JSON-RPC + SSE API. +/// +/// Connects to a running `signal-cli daemon --http `. +/// Listens via SSE at `/api/v1/events` and sends via JSON-RPC at +/// `/api/v1/rpc`. +#[derive(Clone)] +pub struct SignalChannel { + http_url: String, + account: String, + group_id: Option, + allowed_from: Vec, + ignore_attachments: bool, + ignore_stories: bool, + client: Client, +} + +// ── signal-cli SSE event JSON shapes ──────────────────────────── + +#[derive(Debug, Deserialize)] +struct SseEnvelope { + #[serde(default)] + envelope: Option, +} + +#[derive(Debug, Deserialize)] +struct Envelope { + #[serde(default)] + source: Option, + #[serde(rename = "sourceNumber", default)] + source_number: Option, + #[serde(rename = "dataMessage", default)] + data_message: Option, + #[serde(rename = "storyMessage", default)] + story_message: Option, + #[serde(default)] + timestamp: Option, +} + +#[derive(Debug, Deserialize)] +struct DataMessage { + #[serde(default)] + message: Option, + #[serde(default)] + timestamp: Option, + #[serde(rename = "groupInfo", default)] + group_info: Option, + #[serde(default)] + attachments: Option>, +} + +#[derive(Debug, Deserialize)] +struct GroupInfo { + #[serde(rename = "groupId", default)] + group_id: Option, +} + +impl SignalChannel { + pub fn new( + http_url: String, + account: String, + group_id: Option, + allowed_from: Vec, + ignore_attachments: bool, + ignore_stories: bool, + ) -> Self { + let http_url = http_url.trim_end_matches('/').to_string(); + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Signal HTTP client should build"); + Self { + http_url, + account, + group_id, + allowed_from, + ignore_attachments, + ignore_stories, + client, + } + } + + /// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`. + fn sender(envelope: &Envelope) -> Option { + envelope + .source_number + .as_deref() + .or(envelope.source.as_deref()) + .map(String::from) + } + + fn is_sender_allowed(&self, sender: &str) -> bool { + if self.allowed_from.iter().any(|u| u == "*") { + return true; + } + self.allowed_from.iter().any(|u| u == sender) + } + + /// Check whether the message targets the configured group. + /// If no `group_id` is configured (None), all DMs and groups are accepted. + /// Use "dm" to filter DMs only. + fn matches_group(&self, data_msg: &DataMessage) -> bool { + let Some(ref expected) = self.group_id else { + return true; + }; + match data_msg + .group_info + .as_ref() + .and_then(|g| g.group_id.as_deref()) + { + Some(gid) => gid == expected.as_str(), + None => expected.eq_ignore_ascii_case("dm"), + } + } + + /// Determine the send target: group id or the sender's number. + fn reply_target(&self, data_msg: &DataMessage, sender: &str) -> String { + data_msg + .group_info + .as_ref() + .and_then(|g| g.group_id.clone()) + .unwrap_or_else(|| sender.to_string()) + } + + /// Send a JSON-RPC request to signal-cli daemon. + async fn rpc_request( + &self, + method: &str, + params: serde_json::Value, + ) -> anyhow::Result> { + let url = format!("{}/api/v1/rpc", self.http_url); + let id = Uuid::new_v4().to_string(); + + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": id, + }); + + let resp = self + .client + .post(&url) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + // 201 = success with no body (e.g. typing indicators) + if resp.status().as_u16() == 201 { + return Ok(None); + } + + let text = resp.text().await?; + if text.is_empty() { + return Ok(None); + } + + let parsed: serde_json::Value = serde_json::from_str(&text)?; + if let Some(err) = parsed.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1); + let msg = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + anyhow::bail!("Signal RPC error {code}: {msg}"); + } + + Ok(parsed.get("result").cloned()) + } + + /// Process a single SSE envelope, returning a ChannelMessage if valid. + fn process_envelope(&self, envelope: &Envelope) -> Option { + // Skip story messages when configured + if self.ignore_stories && envelope.story_message.is_some() { + return None; + } + + let data_msg = envelope.data_message.as_ref()?; + + // Skip attachment-only messages when configured + if self.ignore_attachments { + let has_attachments = data_msg.attachments.as_ref().is_some_and(|a| !a.is_empty()); + if has_attachments && data_msg.message.is_none() { + return None; + } + } + + let text = data_msg.message.as_deref().filter(|t| !t.is_empty())?; + let sender = Self::sender(envelope)?; + + if !self.is_sender_allowed(&sender) { + return None; + } + + if !self.matches_group(data_msg) { + return None; + } + + let target = self.reply_target(data_msg, &sender); + + let timestamp = data_msg + .timestamp + .or(envelope.timestamp) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + }); + + Some(ChannelMessage { + id: format!("sig_{timestamp}"), + sender: sender.clone(), + reply_target: target, + content: text.to_string(), + channel: "signal".to_string(), + timestamp: timestamp / 1000, // millis → secs + }) + } +} + +#[async_trait] +impl Channel for SignalChannel { + fn name(&self) -> &str { + "signal" + } + + async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> { + let params = if recipient.starts_with('+') { + // DM + serde_json::json!({ + "recipient": [recipient], + "message": message, + "account": self.account, + }) + } else { + // Group + serde_json::json!({ + "groupId": recipient, + "message": message, + "account": self.account, + }) + }; + + self.rpc_request("send", params).await?; + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { + let mut url = reqwest::Url::parse(&format!("{}/api/v1/events", self.http_url))?; + url.query_pairs_mut().append_pair("account", &self.account); + + tracing::info!( + "Signal channel listening via SSE on {} (account {})...", + self.http_url, + self.account + ); + + let mut retry_delay_secs = 2u64; + let max_delay_secs = 60u64; + + loop { + let resp = self + .client + .get(url.clone()) + .header("Accept", "text/event-stream") + .send() + .await; + + let resp = match resp { + Ok(r) if r.status().is_success() => r, + Ok(r) => { + let status = r.status(); + let body = r.text().await.unwrap_or_default(); + tracing::warn!("Signal SSE returned {status}: {body}"); + tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await; + retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs); + continue; + } + Err(e) => { + tracing::warn!("Signal SSE connect error: {e}, retrying..."); + tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await; + retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs); + continue; + } + }; + + retry_delay_secs = 2; + + let mut bytes_stream = resp.bytes_stream(); + let mut buffer = String::new(); + let mut current_data = String::new(); + + while let Some(chunk) = bytes_stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + tracing::debug!("Signal SSE chunk error, reconnecting: {e}"); + break; + } + }; + + let text = match String::from_utf8(chunk.to_vec()) { + Ok(t) => t, + Err(e) => { + tracing::debug!("Signal SSE invalid UTF-8, skipping chunk: {}", e); + continue; + } + }; + + buffer.push_str(&text); + + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + // Skip SSE comments (keepalive) + if line.starts_with(':') { + continue; + } + + if line.is_empty() { + // Empty line = event boundary, dispatch accumulated data + if !current_data.is_empty() { + match serde_json::from_str::(¤t_data) { + Ok(sse) => { + if let Some(ref envelope) = sse.envelope { + if let Some(msg) = self.process_envelope(envelope) { + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + } + Err(e) => { + tracing::debug!("Signal SSE parse skip: {e}"); + } + } + current_data.clear(); + } + } else if let Some(data) = line.strip_prefix("data:") { + if !current_data.is_empty() { + current_data.push('\n'); + } + current_data.push_str(data.trim_start()); + } + // Ignore "event:", "id:", "retry:" lines + } + } + + if !current_data.is_empty() { + match serde_json::from_str::(¤t_data) { + Ok(sse) => { + if let Some(ref envelope) = sse.envelope { + if let Some(msg) = self.process_envelope(envelope) { + let _ = tx.send(msg).await; + } + } + } + Err(e) => { + tracing::debug!("Signal SSE trailing parse skip: {e}"); + } + } + } + + tracing::debug!("Signal SSE stream ended, reconnecting..."); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + } + + async fn health_check(&self) -> bool { + let url = format!("{}/api/v1/check", self.http_url); + let Ok(resp) = self.client.get(&url).send().await else { + return false; + }; + resp.status().is_success() + } + + async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> { + let params = serde_json::json!({ + "recipient": [recipient], + "account": self.account, + }); + self.rpc_request("sendTyping", params).await?; + Ok(()) + } + + async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> { + // signal-cli doesn't have a stop-typing RPC; typing indicators + // auto-expire after ~15s on the client side. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_channel() -> SignalChannel { + SignalChannel::new( + "http://127.0.0.1:8686".to_string(), + "+1234567890".to_string(), + None, + vec!["+1111111111".to_string()], + false, + false, + ) + } + + fn make_channel_with_group(group_id: &str) -> SignalChannel { + SignalChannel::new( + "http://127.0.0.1:8686".to_string(), + "+1234567890".to_string(), + Some(group_id.to_string()), + vec!["*".to_string()], + true, + true, + ) + } + + fn make_envelope(source_number: Option<&str>, message: Option<&str>) -> Envelope { + Envelope { + source: source_number.map(String::from), + source_number: source_number.map(String::from), + data_message: message.map(|m| DataMessage { + message: Some(m.to_string()), + timestamp: Some(1700000000000), + group_info: None, + attachments: None, + }), + story_message: None, + timestamp: Some(1700000000000), + } + } + + #[test] + fn creates_with_correct_fields() { + let ch = make_channel(); + assert_eq!(ch.http_url, "http://127.0.0.1:8686"); + assert_eq!(ch.account, "+1234567890"); + assert!(ch.group_id.is_none()); + assert_eq!(ch.allowed_from.len(), 1); + assert!(!ch.ignore_attachments); + assert!(!ch.ignore_stories); + } + + #[test] + fn strips_trailing_slash() { + let ch = SignalChannel::new( + "http://127.0.0.1:8686/".to_string(), + "+1234567890".to_string(), + None, + vec![], + false, + false, + ); + assert_eq!(ch.http_url, "http://127.0.0.1:8686"); + } + + #[test] + fn wildcard_allows_anyone() { + let ch = make_channel_with_group("dm"); + assert!(ch.is_sender_allowed("+9999999999")); + } + + #[test] + fn specific_sender_allowed() { + let ch = make_channel(); + assert!(ch.is_sender_allowed("+1111111111")); + } + + #[test] + fn unknown_sender_denied() { + let ch = make_channel(); + assert!(!ch.is_sender_allowed("+9999999999")); + } + + #[test] + fn empty_allowlist_denies_all() { + let ch = SignalChannel::new( + "http://127.0.0.1:8686".to_string(), + "+1234567890".to_string(), + None, + vec![], + false, + false, + ); + assert!(!ch.is_sender_allowed("+1111111111")); + } + + #[test] + fn name_returns_signal() { + let ch = make_channel(); + assert_eq!(ch.name(), "signal"); + } + + #[test] + fn matches_group_no_group_id_accepts_all() { + let ch = make_channel(); + let dm = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: None, + attachments: None, + }; + assert!(ch.matches_group(&dm)); + + let group = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: Some(GroupInfo { + group_id: Some("group123".to_string()), + }), + attachments: None, + }; + assert!(ch.matches_group(&group)); + } + + #[test] + fn matches_group_filters_group() { + let ch = make_channel_with_group("group123"); + let matching = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: Some(GroupInfo { + group_id: Some("group123".to_string()), + }), + attachments: None, + }; + assert!(ch.matches_group(&matching)); + + let non_matching = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: Some(GroupInfo { + group_id: Some("other_group".to_string()), + }), + attachments: None, + }; + assert!(!ch.matches_group(&non_matching)); + } + + #[test] + fn matches_group_dm_keyword() { + let ch = make_channel_with_group("dm"); + let dm = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: None, + attachments: None, + }; + assert!(ch.matches_group(&dm)); + + let group = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: Some(GroupInfo { + group_id: Some("group123".to_string()), + }), + attachments: None, + }; + assert!(!ch.matches_group(&group)); + } + + #[test] + fn reply_target_dm() { + let ch = make_channel(); + let dm = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: None, + attachments: None, + }; + assert_eq!(ch.reply_target(&dm, "+1111111111"), "+1111111111"); + } + + #[test] + fn reply_target_group() { + let ch = make_channel(); + let group = DataMessage { + message: Some("hi".to_string()), + timestamp: Some(1000), + group_info: Some(GroupInfo { + group_id: Some("group123".to_string()), + }), + attachments: None, + }; + assert_eq!(ch.reply_target(&group, "+1111111111"), "group123"); + } + + #[test] + fn sender_prefers_source_number() { + let env = Envelope { + source: Some("uuid-123".to_string()), + source_number: Some("+1111111111".to_string()), + data_message: None, + story_message: None, + timestamp: Some(1000), + }; + assert_eq!(SignalChannel::sender(&env), Some("+1111111111".to_string())); + } + + #[test] + fn sender_falls_back_to_source() { + let env = Envelope { + source: Some("uuid-123".to_string()), + source_number: None, + data_message: None, + story_message: None, + timestamp: Some(1000), + }; + assert_eq!(SignalChannel::sender(&env), Some("uuid-123".to_string())); + } + + #[test] + fn sender_none_when_both_missing() { + let env = Envelope { + source: None, + source_number: None, + data_message: None, + story_message: None, + timestamp: None, + }; + assert_eq!(SignalChannel::sender(&env), None); + } + + #[test] + fn process_envelope_valid_dm() { + let ch = make_channel(); + let env = make_envelope(Some("+1111111111"), Some("Hello!")); + let msg = ch.process_envelope(&env).unwrap(); + assert_eq!(msg.content, "Hello!"); + assert_eq!(msg.sender, "+1111111111"); + assert_eq!(msg.channel, "signal"); + } + + #[test] + fn process_envelope_denied_sender() { + let ch = make_channel(); + let env = make_envelope(Some("+9999999999"), Some("Hello!")); + assert!(ch.process_envelope(&env).is_none()); + } + + #[test] + fn process_envelope_empty_message() { + let ch = make_channel(); + let env = make_envelope(Some("+1111111111"), Some("")); + assert!(ch.process_envelope(&env).is_none()); + } + + #[test] + fn process_envelope_no_data_message() { + let ch = make_channel(); + let env = make_envelope(Some("+1111111111"), None); + assert!(ch.process_envelope(&env).is_none()); + } + + #[test] + fn process_envelope_skips_stories() { + let ch = make_channel_with_group("dm"); + let mut env = make_envelope(Some("+1111111111"), Some("story text")); + env.story_message = Some(serde_json::json!({})); + assert!(ch.process_envelope(&env).is_none()); + } + + #[test] + fn process_envelope_skips_attachment_only() { + let ch = make_channel_with_group("dm"); + let env = Envelope { + source: Some("+1111111111".to_string()), + source_number: Some("+1111111111".to_string()), + data_message: Some(DataMessage { + message: None, + timestamp: Some(1700000000000), + group_info: None, + attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]), + }), + story_message: None, + timestamp: Some(1700000000000), + }; + assert!(ch.process_envelope(&env).is_none()); + } + + #[test] + fn sse_envelope_deserializes() { + let json = r#"{ + "envelope": { + "source": "+1111111111", + "sourceNumber": "+1111111111", + "timestamp": 1700000000000, + "dataMessage": { + "message": "Hello Signal!", + "timestamp": 1700000000000 + } + } + }"#; + let sse: SseEnvelope = serde_json::from_str(json).unwrap(); + let env = sse.envelope.unwrap(); + assert_eq!(env.source_number.as_deref(), Some("+1111111111")); + let dm = env.data_message.unwrap(); + assert_eq!(dm.message.as_deref(), Some("Hello Signal!")); + } + + #[test] + fn sse_envelope_deserializes_group() { + let json = r#"{ + "envelope": { + "sourceNumber": "+2222222222", + "dataMessage": { + "message": "Group msg", + "groupInfo": { + "groupId": "abc123" + } + } + } + }"#; + let sse: SseEnvelope = serde_json::from_str(json).unwrap(); + let env = sse.envelope.unwrap(); + let dm = env.data_message.unwrap(); + assert_eq!( + dm.group_info.as_ref().unwrap().group_id.as_deref(), + Some("abc123") + ); + } + + #[test] + fn envelope_defaults() { + let json = r#"{}"#; + let env: Envelope = serde_json::from_str(json).unwrap(); + assert!(env.source.is_none()); + assert!(env.source_number.is_none()); + assert!(env.data_message.is_none()); + assert!(env.story_message.is_none()); + assert!(env.timestamp.is_none()); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 74f5d34..54619dd 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1277,6 +1277,7 @@ pub struct ChannelsConfig { pub webhook: Option, pub imessage: Option, pub matrix: Option, + pub signal: Option, pub whatsapp: Option, pub email: Option, pub irc: Option, @@ -1294,6 +1295,7 @@ impl Default for ChannelsConfig { webhook: None, imessage: None, matrix: None, + signal: None, whatsapp: None, email: None, irc: None, @@ -1353,6 +1355,29 @@ pub struct MatrixConfig { pub allowed_users: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignalConfig { + /// Base URL for the signal-cli HTTP daemon (e.g. "http://127.0.0.1:8686"). + pub http_url: String, + /// E.164 phone number of the signal-cli account (e.g. "+1234567890"). + pub account: String, + /// Optional group ID to filter messages. + /// - `None` or omitted: accept all messages (DMs and groups) + /// - `"dm"`: only accept direct messages + /// - Specific group ID: only accept messages from that group + #[serde(default)] + pub group_id: Option, + /// Allowed sender phone numbers (E.164) or "*" for all. + #[serde(default)] + pub allowed_from: Vec, + /// Skip messages that are attachment-only (no text body). + #[serde(default)] + pub ignore_attachments: bool, + /// Skip incoming story messages. + #[serde(default)] + pub ignore_stories: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WhatsAppConfig { /// Access token from Meta Business Suite @@ -2133,6 +2158,7 @@ default_temperature = 0.7 webhook: None, imessage: None, matrix: None, + signal: None, whatsapp: None, email: None, irc: None, @@ -2481,6 +2507,54 @@ tool_dispatcher = "xml" assert_eq!(parsed.allowed_users.len(), 2); } + #[test] + fn signal_config_serde() { + let sc = SignalConfig { + http_url: "http://127.0.0.1:8686".into(), + account: "+1234567890".into(), + group_id: Some("group123".into()), + allowed_from: vec!["+1111111111".into()], + ignore_attachments: true, + ignore_stories: false, + }; + let json = serde_json::to_string(&sc).unwrap(); + let parsed: SignalConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.http_url, "http://127.0.0.1:8686"); + assert_eq!(parsed.account, "+1234567890"); + assert_eq!(parsed.group_id.as_deref(), Some("group123")); + assert_eq!(parsed.allowed_from.len(), 1); + assert!(parsed.ignore_attachments); + assert!(!parsed.ignore_stories); + } + + #[test] + fn signal_config_toml_roundtrip() { + let sc = SignalConfig { + http_url: "http://localhost:8080".into(), + account: "+9876543210".into(), + group_id: None, + allowed_from: vec!["*".into()], + ignore_attachments: false, + ignore_stories: true, + }; + let toml_str = toml::to_string(&sc).unwrap(); + let parsed: SignalConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.http_url, "http://localhost:8080"); + assert_eq!(parsed.account, "+9876543210"); + assert!(parsed.group_id.is_none()); + assert!(parsed.ignore_stories); + } + + #[test] + fn signal_config_defaults() { + let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#; + let parsed: SignalConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.group_id.is_none()); + assert!(parsed.allowed_from.is_empty()); + assert!(!parsed.ignore_attachments); + assert!(!parsed.ignore_stories); + } + #[test] fn channels_config_with_imessage_and_matrix() { let c = ChannelsConfig { @@ -2498,6 +2572,7 @@ tool_dispatcher = "xml" room_id: "!r:m".into(), allowed_users: vec!["@u:m".into()], }), + signal: None, whatsapp: None, email: None, irc: None, @@ -2652,6 +2727,7 @@ channel_id = "C123" webhook: None, imessage: None, matrix: None, + signal: None, whatsapp: Some(WhatsAppConfig { access_token: "tok".into(), phone_number_id: "123".into(), diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index a223597..bcd5a66 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -214,9 +214,12 @@ fn has_supervised_channels(config: &Config) -> bool { || config.channels_config.slack.is_some() || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() + || config.channels_config.signal.is_some() || config.channels_config.whatsapp.is_some() || config.channels_config.email.is_some() + || config.channels_config.irc.is_some() || config.channels_config.lark.is_some() + || config.channels_config.dingtalk.is_some() } #[cfg(test)] diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index b368d7e..d725e3b 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -69,7 +69,13 @@ pub fn all_integrations() -> Vec { name: "Signal", description: "Privacy-focused via signal-cli", category: IntegrationCategory::Chat, - status_fn: |_| IntegrationStatus::ComingSoon, + status_fn: |c| { + if c.channels_config.signal.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, }, IntegrationEntry { name: "iMessage", @@ -822,7 +828,7 @@ mod tests { fn coming_soon_integrations_stay_coming_soon() { let config = Config::default(); let entries = all_integrations(); - for name in ["Signal", "Nostr", "Spotify", "Home Assistant"] { + for name in ["Nostr", "Spotify", "Home Assistant"] { let entry = entries.iter().find(|e| e.name == name).unwrap(); assert!( matches!((entry.status_fn)(&config), IntegrationStatus::ComingSoon), diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 0422e45..9e05f68 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -2305,6 +2305,7 @@ fn setup_channels() -> Result { webhook: None, imessage: None, matrix: None, + signal: None, whatsapp: None, email: None, irc: None,