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:
bhagwan 2026-02-17 08:52:49 -05:00 committed by Chummy
parent acfdc34be2
commit 55f2637cfe
6 changed files with 860 additions and 2 deletions

View file

@ -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(),