feat(channel): add Signal channel via signal-cli JSON-RPC daemon
Adds a new Signal messaging channel that connects to a running signal-cli daemon's native HTTP API (JSON-RPC + SSE). [channels_config.signal] http_url = "http://127.0.0.1:8686" account = "+1234567890" group_id = "group_id" # optional, omit for all allowed_from = ["+1111111111"] ignore_attachments = true ignore_stories = true Implementation: - SSE listener at /api/v1/events for incoming messages - JSON-RPC sends via /api/v1/rpc (method: send) - Health check via /api/v1/check - Typing indicators via sendTyping RPC - Supports DMs and group messages (room_id filtering) - Allowlist-based sender filtering (E.164 or wildcard) - Optional attachment/story filtering - Fixed has_supervised_channels() to include signal + irc/lark/dingtalk Registered in channel list, doctor, start, integrations registry, and daemon supervisor gate. Includes unit tests for config serde, sender filtering, room matching, envelope processing, and deserialization. No new dependencies (uses existing uuid, futures-util, reqwest).
This commit is contained in:
parent
acfdc34be2
commit
55f2637cfe
6 changed files with 860 additions and 2 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
744
src/channels/signal.rs
Normal file
744
src/channels/signal.rs
Normal file
|
|
@ -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 <host:port>`.
|
||||
/// 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<String>,
|
||||
allowed_from: Vec<String>,
|
||||
ignore_attachments: bool,
|
||||
ignore_stories: bool,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
// ── signal-cli SSE event JSON shapes ────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SseEnvelope {
|
||||
#[serde(default)]
|
||||
envelope: Option<Envelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Envelope {
|
||||
#[serde(default)]
|
||||
source: Option<String>,
|
||||
#[serde(rename = "sourceNumber", default)]
|
||||
source_number: Option<String>,
|
||||
#[serde(rename = "dataMessage", default)]
|
||||
data_message: Option<DataMessage>,
|
||||
#[serde(rename = "storyMessage", default)]
|
||||
story_message: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DataMessage {
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
timestamp: Option<u64>,
|
||||
#[serde(rename = "groupInfo", default)]
|
||||
group_info: Option<GroupInfo>,
|
||||
#[serde(default)]
|
||||
attachments: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GroupInfo {
|
||||
#[serde(rename = "groupId", default)]
|
||||
group_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SignalChannel {
|
||||
pub fn new(
|
||||
http_url: String,
|
||||
account: String,
|
||||
group_id: Option<String>,
|
||||
allowed_from: Vec<String>,
|
||||
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<String> {
|
||||
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<Option<serde_json::Value>> {
|
||||
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<ChannelMessage> {
|
||||
// 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<ChannelMessage>) -> 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::<SseEnvelope>(¤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::<SseEnvelope>(¤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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1277,6 +1277,7 @@ pub struct ChannelsConfig {
|
|||
pub webhook: Option<WebhookConfig>,
|
||||
pub imessage: Option<IMessageConfig>,
|
||||
pub matrix: Option<MatrixConfig>,
|
||||
pub signal: Option<SignalConfig>,
|
||||
pub whatsapp: Option<WhatsAppConfig>,
|
||||
pub email: Option<crate::channels::email_channel::EmailConfig>,
|
||||
pub irc: Option<IrcConfig>,
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Allowed sender phone numbers (E.164) or "*" for all.
|
||||
#[serde(default)]
|
||||
pub allowed_from: Vec<String>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -69,7 +69,13 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -2305,6 +2305,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
webhook: None,
|
||||
imessage: None,
|
||||
matrix: None,
|
||||
signal: None,
|
||||
whatsapp: None,
|
||||
email: None,
|
||||
irc: None,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue