mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
* feat(web): show error details and input params for failed tool calls Failed tool calls in the gateway UI previously showed only a red X icon with an empty expandable body. This change: - Adds optional `error` and `parameters` fields to `ToolCompleted` SSE events so the browser receives failure details in real-time - Auto-expands failed tool cards to make errors immediately visible - Adds `StatusUpdate::tool_completed()` constructor that centralizes the 5 duplicated construction sites and applies `redact_params()` to prevent sensitive values (e.g. secret_save's "value" param) from leaking through SSE broadcasts - Adds `sensitive_params()` trait method to `Tool` for declaring which parameters must be redacted before logging, hooks, and UI display - Adds `redact_params()` utility and wires it through hooks, approvals, ActionRecord storage, and debug logs in dispatcher/worker - Adds `SecretListTool` and `SecretDeleteTool` for LLM-driven secret management (values never returned, only names/metadata) - Fixes auth flow: setup-only extensions show configure modal instead of OAuth card; auth_completed SSE dismisses both UI paths - CI: release workflow creates PR instead of pushing directly to main - Registry: MissingChecksum error enables source fallback for bootstrapping when checksums haven't been populated yet Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: apply cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: keep original params in PendingApproval for execution, redact only for display Address two PR review comments: 1. execute_chat_tool_standalone now redacts sensitive params before logging, matching the pattern already used in worker.rs. 2. PendingApproval previously stored redacted parameters, which meant approved tool calls received "[REDACTED]" instead of the actual values. Add a display_parameters field for UI/logs and keep parameters as the original values used for execution. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments - worker.rs: redact sensitive params before BeforeToolCall hook, matching dispatcher.rs — hooks in the autonomous job path now receive redacted params instead of raw values - registry.rs: fix docstring for register_secrets_tools (list, delete, not save/list/delete — no SecretSaveTool is registered) - app.js: fix double toast/loadExtensions in submitConfigureModal — for non-OAuth success the auth_completed SSE already handles both, so skip them in the HTTP response handler to avoid duplicates [skip-regression-check] Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2767 lines
101 KiB
Rust
2767 lines
101 KiB
Rust
//! Signal channel via signal-cli daemon HTTP/JSON-RPC.
|
||
//!
|
||
//! Connects to a running `signal-cli daemon --http <host:port>`.
|
||
//! Listens for messages via SSE at `/api/v1/events` and sends via
|
||
//! JSON-RPC at `/api/v1/rpc`.
|
||
|
||
use std::num::NonZeroUsize;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use async_trait::async_trait;
|
||
use futures::StreamExt;
|
||
use lru::LruCache;
|
||
use reqwest::Client;
|
||
use serde::Deserialize;
|
||
use tokio::sync::RwLock;
|
||
use uuid::Uuid;
|
||
|
||
use crate::bootstrap::ironclaw_base_dir;
|
||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||
use crate::config::SignalConfig;
|
||
use crate::error::ChannelError;
|
||
use crate::pairing::PairingStore;
|
||
|
||
const GROUP_TARGET_PREFIX: &str = "group:";
|
||
const SIGNAL_HEALTH_ENDPOINT: &str = "/api/v1/check";
|
||
|
||
const MAX_SSE_BUFFER_SIZE: usize = 1024 * 1024;
|
||
const MAX_SSE_EVENT_SIZE: usize = 256 * 1024;
|
||
const MAX_HTTP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
|
||
const MAX_REPLY_TARGETS: usize = 10000;
|
||
const MAX_ERROR_LOG_BODY: usize = 1024;
|
||
|
||
const REPLY_TARGETS_CAP: NonZeroUsize = NonZeroUsize::new(MAX_REPLY_TARGETS).unwrap();
|
||
|
||
/// Recipient classification for outbound messages.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
enum RecipientTarget {
|
||
Direct(String),
|
||
Group(String),
|
||
}
|
||
|
||
// ── 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 = "sourceName", default)]
|
||
source_name: Option<String>,
|
||
#[serde(rename = "sourceUuid", default)]
|
||
source_uuid: 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>,
|
||
}
|
||
|
||
/// Signal channel using signal-cli daemon's native JSON-RPC + SSE API.
|
||
pub struct SignalChannel {
|
||
config: SignalConfig,
|
||
client: Client,
|
||
/// LRU cache of reply targets per incoming message, used by `respond()`.
|
||
/// Bounded to `MAX_REPLY_TARGETS` entries; least-recently-used entries
|
||
/// are evicted automatically when the cache is full.
|
||
reply_targets: Arc<RwLock<LruCache<Uuid, String>>>,
|
||
/// Debug mode for verbose tool output (toggled via /debug command).
|
||
debug_mode: Arc<AtomicBool>,
|
||
}
|
||
|
||
impl SignalChannel {
|
||
/// Create a new Signal channel with normalized config and fresh client/cache.
|
||
pub fn new(config: SignalConfig) -> Result<Self, ChannelError> {
|
||
let mut config = config;
|
||
config.http_url = config.http_url.trim_end_matches('/').to_string();
|
||
|
||
let client = Client::builder()
|
||
.connect_timeout(Duration::from_secs(10))
|
||
.build()
|
||
.map_err(|e| ChannelError::Http(e.to_string()))?;
|
||
|
||
let cap = REPLY_TARGETS_CAP;
|
||
let reply_targets = Arc::new(RwLock::new(LruCache::new(cap)));
|
||
let debug_mode = Arc::new(AtomicBool::new(false));
|
||
|
||
Ok(Self::from_parts(config, client, reply_targets, debug_mode))
|
||
}
|
||
|
||
/// Construct a SignalChannel from pre-validated parts.
|
||
///
|
||
/// Used by [`new()`][Self::new] after normalization and by [`sse_listener`]
|
||
/// to ensure both code paths use the same constructor.
|
||
fn from_parts(
|
||
config: SignalConfig,
|
||
client: Client,
|
||
reply_targets: Arc<RwLock<LruCache<Uuid, String>>>,
|
||
debug_mode: Arc<AtomicBool>,
|
||
) -> Self {
|
||
Self {
|
||
config,
|
||
client,
|
||
reply_targets,
|
||
debug_mode,
|
||
}
|
||
}
|
||
|
||
fn is_debug(&self) -> bool {
|
||
self.debug_mode.load(Ordering::Relaxed)
|
||
}
|
||
|
||
fn toggle_debug(&self) -> bool {
|
||
let current = self.debug_mode.load(Ordering::Relaxed);
|
||
self.debug_mode.store(!current, Ordering::Relaxed);
|
||
!current
|
||
}
|
||
|
||
/// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`
|
||
/// (UUID for privacy-enabled users).
|
||
fn sender(envelope: &Envelope) -> Option<String> {
|
||
envelope
|
||
.source_number
|
||
.as_deref()
|
||
.or(envelope.source.as_deref())
|
||
.map(String::from)
|
||
}
|
||
|
||
/// Normalize an allowlist entry to the bare identifier.
|
||
///
|
||
/// Strips the `uuid:` prefix if present, so `uuid:<id>` and `<id>` both
|
||
/// match against a bare UUID sender.
|
||
fn normalize_allow_entry(entry: &str) -> &str {
|
||
entry.strip_prefix("uuid:").unwrap_or(entry)
|
||
}
|
||
|
||
/// Check whether a sender is in the allowed users list.
|
||
fn is_sender_allowed(&self, sender: &str) -> bool {
|
||
if self.config.allow_from.is_empty() {
|
||
return false;
|
||
}
|
||
self.config.allow_from.iter().any(|entry| {
|
||
entry == "*"
|
||
|| Self::normalize_allow_entry(entry) == Self::normalize_allow_entry(sender)
|
||
})
|
||
}
|
||
|
||
/// Check if sender is allowed via config allow_from OR pairing store.
|
||
fn is_sender_allowed_with_pairing(&self, sender: &str) -> bool {
|
||
if self.is_sender_allowed(sender) {
|
||
return true;
|
||
}
|
||
let store = PairingStore::new();
|
||
if let Ok(allowed) = store.read_allow_from("signal") {
|
||
return allowed.iter().any(|entry| entry == "*" || entry == sender);
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Handle pairing request for unapproved sender.
|
||
/// Returns Ok(true) if message should be allowed (was already paired),
|
||
/// Ok(false) if message was blocked but pairing request was processed.
|
||
fn handle_pairing_request(&self, sender: &str, source_name: Option<&str>) -> Result<bool, ()> {
|
||
let store = PairingStore::new();
|
||
let meta = serde_json::json!({
|
||
"sender": sender,
|
||
"name": source_name,
|
||
});
|
||
|
||
match store.upsert_request("signal", sender, Some(meta)) {
|
||
Ok(result) => {
|
||
tracing::info!(
|
||
sender = %sender,
|
||
code = %result.code,
|
||
"Signal: pairing request upserted"
|
||
);
|
||
if result.created {
|
||
let message = format!(
|
||
"To pair with this bot, run: `ironclaw pairing approve signal {}`",
|
||
result.code
|
||
);
|
||
let http_url = self.config.http_url.clone();
|
||
let account = self.config.account.clone();
|
||
let sender_owned = sender.to_string();
|
||
let message_owned = message.clone();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = Self::send_pairing_reply_async(
|
||
&http_url,
|
||
&account,
|
||
&sender_owned,
|
||
&message_owned,
|
||
)
|
||
.await
|
||
{
|
||
tracing::error!(sender = %sender_owned, error = %e, "Signal: failed to send pairing reply");
|
||
}
|
||
});
|
||
}
|
||
Ok(false)
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(sender = %sender, error = %e, "Signal: pairing upsert failed");
|
||
Err(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Send a pairing reply message to the sender (async helper for spawned task).
|
||
async fn send_pairing_reply_async(
|
||
http_url: &str,
|
||
account: &str,
|
||
recipient: &str,
|
||
message: &str,
|
||
) -> Result<(), ChannelError> {
|
||
let client = Client::builder()
|
||
.connect_timeout(Duration::from_secs(10))
|
||
.build()
|
||
.map_err(|e| ChannelError::Http(e.to_string()))?;
|
||
|
||
let target = Self::parse_recipient_target(recipient);
|
||
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message), None);
|
||
|
||
let url = format!("{}/api/v1/rpc", http_url);
|
||
let id = Uuid::new_v4().to_string();
|
||
|
||
let body = serde_json::json!({
|
||
"jsonrpc": "2.0",
|
||
"method": "send",
|
||
"params": params,
|
||
"id": id,
|
||
});
|
||
|
||
let resp = client
|
||
.post(&url)
|
||
.timeout(Duration::from_secs(30))
|
||
.header("Content-Type", "application/json")
|
||
.json(&body)
|
||
.send()
|
||
.await
|
||
.map_err(|e| ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("RPC request failed to {}: {e}", Self::redact_url(&url)),
|
||
})?;
|
||
|
||
let status = resp.status();
|
||
let is_success = status.is_success();
|
||
|
||
if status.as_u16() == 201 {
|
||
return Ok(());
|
||
}
|
||
|
||
if !is_success {
|
||
let bytes = resp.bytes().await.unwrap_or_default();
|
||
let truncated_len = bytes.len().min(MAX_ERROR_LOG_BODY);
|
||
let truncated_body = String::from_utf8_lossy(&bytes[..truncated_len]);
|
||
return Err(ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("HTTP error {}: {}", status.as_u16(), truncated_body),
|
||
});
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Get effective group allow_from list (inherits from allow_from if empty).
|
||
fn effective_group_allow_from(&self) -> &[String] {
|
||
if self.config.group_allow_from.is_empty() {
|
||
&self.config.allow_from
|
||
} else {
|
||
&self.config.group_allow_from
|
||
}
|
||
}
|
||
|
||
/// Check whether a group is in the allowed groups list.
|
||
///
|
||
/// - Empty list — deny all groups (DMs only, secure by default).
|
||
/// - `*` — allow all groups.
|
||
/// - Specific IDs — allow only those groups.
|
||
fn is_group_allowed(&self, group_id: &str) -> bool {
|
||
if self.config.allow_from_groups.is_empty() {
|
||
return false;
|
||
}
|
||
self.config
|
||
.allow_from_groups
|
||
.iter()
|
||
.any(|entry| entry == "*" || entry == group_id)
|
||
}
|
||
|
||
/// Check whether a sender is allowed for group messages.
|
||
fn is_group_sender_allowed(&self, sender: &str) -> bool {
|
||
let effective_list = self.effective_group_allow_from();
|
||
if effective_list.is_empty() {
|
||
return false;
|
||
}
|
||
effective_list.iter().any(|entry| {
|
||
entry == "*"
|
||
|| Self::normalize_allow_entry(entry) == Self::normalize_allow_entry(sender)
|
||
})
|
||
}
|
||
|
||
/// Redact credentials from a URL for safe logging.
|
||
///
|
||
/// Replaces any embedded username/password with `**REDACTED**` and returns
|
||
/// the sanitised string. Returns `"<invalid-url>"` when parsing fails.
|
||
pub fn redact_url(url: &str) -> String {
|
||
reqwest::Url::parse(url)
|
||
.map(|mut u| {
|
||
if u.password().is_some() || !u.username().is_empty() {
|
||
let _ = u.set_username("**REDACTED**");
|
||
let _ = u.set_password(None);
|
||
}
|
||
u.to_string()
|
||
})
|
||
.unwrap_or_else(|_| "<invalid-url>".to_string())
|
||
}
|
||
|
||
fn is_e164(recipient: &str) -> bool {
|
||
let Some(number) = recipient.strip_prefix('+') else {
|
||
return false;
|
||
};
|
||
(7..=15).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit())
|
||
}
|
||
|
||
/// Check whether a string is a valid UUID (signal-cli uses these for
|
||
/// privacy-enabled users who have opted out of sharing their phone number).
|
||
fn is_uuid(s: &str) -> bool {
|
||
Uuid::parse_str(s).is_ok()
|
||
}
|
||
|
||
/// Generate a deterministic UUID from an identifier (phone number or group ID).
|
||
///
|
||
/// This ensures that the same phone number or group always produces the same UUID,
|
||
/// allowing conversation history to persist across gateway restarts.
|
||
fn thread_id_from_identifier(identifier: &str) -> String {
|
||
// Use a stable, deterministic UUID v5 derived from the identifier.
|
||
// This avoids relying on `DefaultHasher` implementation details and
|
||
// provides a full 128 bits of entropy.
|
||
Uuid::new_v5(&Uuid::NAMESPACE_URL, identifier.as_bytes()).to_string()
|
||
}
|
||
|
||
fn parse_recipient_target(recipient: &str) -> RecipientTarget {
|
||
if let Some(group_id) = recipient.strip_prefix(GROUP_TARGET_PREFIX) {
|
||
return RecipientTarget::Group(group_id.to_string());
|
||
}
|
||
|
||
if Self::is_e164(recipient) || Self::is_uuid(recipient) {
|
||
RecipientTarget::Direct(recipient.to_string())
|
||
} else {
|
||
RecipientTarget::Group(recipient.to_string())
|
||
}
|
||
}
|
||
|
||
/// Determine the reply target: group id (prefixed) or the sender's identifier.
|
||
fn reply_target(data_msg: &DataMessage, sender: &str) -> String {
|
||
if let Some(group_id) = data_msg
|
||
.group_info
|
||
.as_ref()
|
||
.and_then(|g| g.group_id.as_deref())
|
||
{
|
||
format!("{GROUP_TARGET_PREFIX}{group_id}")
|
||
} else {
|
||
sender.to_string()
|
||
}
|
||
}
|
||
|
||
/// Send a JSON-RPC request to signal-cli daemon.
|
||
async fn rpc_request(
|
||
&self,
|
||
method: &str,
|
||
params: serde_json::Value,
|
||
) -> Result<Option<serde_json::Value>, ChannelError> {
|
||
let url = format!("{}/api/v1/rpc", self.config.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)
|
||
.timeout(Duration::from_secs(30))
|
||
.header("Content-Type", "application/json")
|
||
.json(&body)
|
||
.send()
|
||
.await
|
||
.map_err(|e| ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("RPC request failed to {}: {e}", Self::redact_url(&url)),
|
||
})?;
|
||
|
||
// 201 = success with no body (e.g. typing indicators).
|
||
if resp.status().as_u16() == 201 {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Reject obviously oversized responses before buffering.
|
||
if let Some(len) = resp.content_length()
|
||
&& len as usize > MAX_HTTP_RESPONSE_SIZE
|
||
{
|
||
return Err(ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!(
|
||
"RPC response Content-Length too large: {} bytes (max {})",
|
||
len, MAX_HTTP_RESPONSE_SIZE
|
||
),
|
||
});
|
||
}
|
||
|
||
let status = resp.status();
|
||
let mut stream = resp.bytes_stream();
|
||
let mut total_bytes = 0usize;
|
||
let mut body = Vec::new();
|
||
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk = chunk.map_err(|e| ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("Failed to read RPC response: {e}"),
|
||
})?;
|
||
let chunk_len = chunk.len();
|
||
total_bytes += chunk_len;
|
||
|
||
if total_bytes > MAX_HTTP_RESPONSE_SIZE {
|
||
return Err(ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!(
|
||
"RPC response too large: {} bytes (max {})",
|
||
total_bytes, MAX_HTTP_RESPONSE_SIZE
|
||
),
|
||
});
|
||
}
|
||
|
||
body.extend_from_slice(&chunk);
|
||
}
|
||
|
||
let bytes = body;
|
||
|
||
if bytes.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Check for non-success HTTP status codes before parsing as JSON.
|
||
if !status.is_success() {
|
||
let truncated_len = std::cmp::min(bytes.len(), 512);
|
||
let truncated_body = String::from_utf8_lossy(&bytes[..truncated_len]);
|
||
return Err(ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("HTTP error {}: {}", status.as_u16(), truncated_body),
|
||
});
|
||
}
|
||
|
||
let parsed: serde_json::Value =
|
||
serde_json::from_slice(&bytes).map_err(|e| ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("Invalid RPC response JSON: {e}"),
|
||
})?;
|
||
|
||
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");
|
||
return Err(ChannelError::SendFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("Signal RPC error {code}: {msg}"),
|
||
});
|
||
}
|
||
|
||
Ok(parsed.get("result").cloned())
|
||
}
|
||
|
||
/// Build JSON-RPC params for a send/typing call.
|
||
fn build_rpc_params(
|
||
&self,
|
||
target: &RecipientTarget,
|
||
message: Option<&str>,
|
||
attachments: Option<&[String]>,
|
||
) -> serde_json::Value {
|
||
match target {
|
||
RecipientTarget::Direct(id) => {
|
||
let mut params = serde_json::json!({
|
||
"recipient": [id],
|
||
"account": &self.config.account,
|
||
});
|
||
if let Some(msg) = message {
|
||
params["message"] = serde_json::Value::String(msg.to_string());
|
||
}
|
||
if let Some(attachments) = attachments
|
||
&& !attachments.is_empty()
|
||
{
|
||
params["attachments"] = serde_json::Value::Array(
|
||
attachments
|
||
.iter()
|
||
.map(|s| serde_json::Value::String(s.clone()))
|
||
.collect(),
|
||
);
|
||
}
|
||
params
|
||
}
|
||
RecipientTarget::Group(group_id) => {
|
||
let mut params = serde_json::json!({
|
||
"groupId": group_id,
|
||
"account": &self.config.account,
|
||
});
|
||
if let Some(msg) = message {
|
||
params["message"] = serde_json::Value::String(msg.to_string());
|
||
}
|
||
if let Some(attachments) = attachments
|
||
&& !attachments.is_empty()
|
||
{
|
||
params["attachments"] = serde_json::Value::Array(
|
||
attachments
|
||
.iter()
|
||
.map(|s| serde_json::Value::String(s.clone()))
|
||
.collect(),
|
||
);
|
||
}
|
||
params
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Validate that attachment paths are safe and within the sandbox.
|
||
/// Uses the shared path validation logic from path_utils to ensure:
|
||
/// - No path traversal attacks (../, URL-encoded, null bytes)
|
||
/// - Paths are canonicalized and symlinks resolved
|
||
/// - All paths are within ~/.ironclaw/ sandbox
|
||
fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> {
|
||
// Get the sandbox base directory (same as MessageTool uses)
|
||
let base_dir = ironclaw_base_dir();
|
||
|
||
for path in paths {
|
||
crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err(
|
||
|e| {
|
||
ChannelError::InvalidMessage(format!(
|
||
"Attachment path must be within {}: {}",
|
||
base_dir.display(),
|
||
e
|
||
))
|
||
},
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Send a message with attachments (if any).
|
||
/// Combines text and attachments into a single RPC call when both are present.
|
||
async fn send_with_attachments(
|
||
&self,
|
||
target: &RecipientTarget,
|
||
content: &str,
|
||
attachments: &[String],
|
||
) -> Result<(), ChannelError> {
|
||
Self::validate_attachment_paths(attachments)?;
|
||
|
||
if attachments.is_empty() {
|
||
let params = self.build_rpc_params(target, Some(content), None);
|
||
self.rpc_request("send", params).await?;
|
||
} else if content.is_empty() {
|
||
// Attachments only - send all in a single call with no message text
|
||
let params = self.build_rpc_params(target, None, Some(attachments));
|
||
self.rpc_request("send", params).await?;
|
||
} else {
|
||
// Both text and attachments - send in a single RPC call
|
||
let params = self.build_rpc_params(target, Some(content), Some(attachments));
|
||
self.rpc_request("send", params).await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Build JSON-RPC params for a send/typing call (static version).
|
||
fn build_rpc_params_static(
|
||
_http_url: &str,
|
||
account: &str,
|
||
target: &RecipientTarget,
|
||
message: Option<&str>,
|
||
attachments: Option<&[String]>,
|
||
) -> serde_json::Value {
|
||
match target {
|
||
RecipientTarget::Direct(id) => {
|
||
let mut params = serde_json::json!({
|
||
"recipient": [id],
|
||
"account": account,
|
||
});
|
||
if let Some(msg) = message {
|
||
params["message"] = serde_json::Value::String(msg.to_string());
|
||
}
|
||
if let Some(attachments) = attachments
|
||
&& !attachments.is_empty()
|
||
{
|
||
params["attachments"] = serde_json::Value::Array(
|
||
attachments
|
||
.iter()
|
||
.map(|s| serde_json::Value::String(s.clone()))
|
||
.collect(),
|
||
);
|
||
}
|
||
params
|
||
}
|
||
RecipientTarget::Group(group_id) => {
|
||
let mut params = serde_json::json!({
|
||
"groupId": group_id,
|
||
"account": account,
|
||
});
|
||
if let Some(msg) = message {
|
||
params["message"] = serde_json::Value::String(msg.to_string());
|
||
}
|
||
if let Some(attachments) = attachments
|
||
&& !attachments.is_empty()
|
||
{
|
||
params["attachments"] = serde_json::Value::Array(
|
||
attachments
|
||
.iter()
|
||
.map(|s| serde_json::Value::String(s.clone()))
|
||
.collect(),
|
||
);
|
||
}
|
||
params
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Process a single SSE envelope, returning an `IncomingMessage` if valid.
|
||
fn process_envelope(&self, envelope: &Envelope) -> Option<(IncomingMessage, String)> {
|
||
// Skip story messages when configured.
|
||
if self.config.ignore_stories && envelope.story_message.is_some() {
|
||
tracing::debug!("Signal: dropping story message");
|
||
return None;
|
||
}
|
||
|
||
let data_msg = envelope.data_message.as_ref()?;
|
||
|
||
// Skip attachment-only messages when configured.
|
||
let has_attachments = data_msg.attachments.as_ref().is_some_and(|a| !a.is_empty());
|
||
let has_message_text = data_msg.message.as_ref().is_some_and(|m| !m.is_empty());
|
||
if self.config.ignore_attachments && has_attachments && !has_message_text {
|
||
tracing::debug!("Signal: dropping attachment-only message");
|
||
return None;
|
||
}
|
||
|
||
// Use message text, or fall back to "[Attachment]" for attachment-only messages
|
||
// when ignore_attachments is false. This ensures attachment-only messages are
|
||
// still processed when the user wants them (rather than always being dropped).
|
||
let text = data_msg
|
||
.message
|
||
.as_deref()
|
||
.filter(|t| !t.is_empty())
|
||
.map(String::from)
|
||
.or_else(|| {
|
||
if has_attachments {
|
||
Some("[Attachment]".to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})?;
|
||
let sender = Self::sender(envelope)?;
|
||
|
||
// Log sender info including UUID if available
|
||
tracing::debug!(
|
||
sender = %sender,
|
||
uuid = ?envelope.source_uuid,
|
||
"Signal: received message"
|
||
);
|
||
|
||
// Check if this is a group message
|
||
let is_group = data_msg
|
||
.group_info
|
||
.as_ref()
|
||
.and_then(|g| g.group_id.as_deref())
|
||
.is_some();
|
||
|
||
// Apply group policy first (before DM policy for group messages)
|
||
if is_group {
|
||
match self.config.group_policy.as_str() {
|
||
"disabled" => {
|
||
tracing::debug!("Signal: group messages disabled, dropping");
|
||
return None;
|
||
}
|
||
"open" => {
|
||
// For "open" policy, check group allowlist but not sender allowlist
|
||
if let Some(group_id) = data_msg
|
||
.group_info
|
||
.as_ref()
|
||
.and_then(|g| g.group_id.as_deref())
|
||
&& !self.is_group_allowed(group_id)
|
||
{
|
||
tracing::debug!(
|
||
group_id = %group_id,
|
||
"Signal: group not in allow_from_groups, dropping"
|
||
);
|
||
return None;
|
||
}
|
||
}
|
||
"allowlist" => {
|
||
// Default to allowlist - check group AND sender
|
||
if let Some(group_id) = data_msg
|
||
.group_info
|
||
.as_ref()
|
||
.and_then(|g| g.group_id.as_deref())
|
||
{
|
||
if !self.is_group_allowed(group_id) {
|
||
tracing::debug!(
|
||
group_id = %group_id,
|
||
"Signal: group not in allow_from_groups, dropping"
|
||
);
|
||
return None;
|
||
}
|
||
// Also check sender is allowed for group
|
||
if !self.is_group_sender_allowed(&sender) {
|
||
tracing::debug!(
|
||
sender = %sender,
|
||
group_id = %group_id,
|
||
"Signal: sender not in group_allow_from, dropping"
|
||
);
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
} else {
|
||
// DM message - apply DM policy
|
||
match self.config.dm_policy.as_str() {
|
||
"open" => {}
|
||
"pairing" => {
|
||
// Pairing policy: check allow_from + pairing store
|
||
if !self.is_sender_allowed_with_pairing(&sender) {
|
||
// Handle pairing request - this will create a request and send reply if new
|
||
match self.handle_pairing_request(&sender, envelope.source_name.as_deref())
|
||
{
|
||
Ok(_) => {
|
||
// Pairing request processed (new or existing), drop the message
|
||
return None;
|
||
}
|
||
Err(()) => {
|
||
// Error processing pairing, drop message
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"allowlist" => {
|
||
// Default: check allow_from list
|
||
if !self.is_sender_allowed(&sender) {
|
||
tracing::debug!(sender = %sender, "Signal: sender not in allow_from, dropping");
|
||
return None;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let target = Self::reply_target(data_msg, &sender);
|
||
|
||
let timestamp = data_msg
|
||
.timestamp
|
||
.or(envelope.timestamp)
|
||
.unwrap_or_else(|| {
|
||
u64::try_from(
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis(),
|
||
)
|
||
.unwrap_or(u64::MAX)
|
||
});
|
||
|
||
// Build metadata with signal-specific routing info.
|
||
let sender_uuid = envelope.source_uuid.as_deref();
|
||
let metadata = serde_json::json!({
|
||
"signal_sender": &sender,
|
||
"signal_sender_uuid": sender_uuid,
|
||
"signal_target": &target,
|
||
"signal_timestamp": timestamp,
|
||
});
|
||
|
||
let mut msg = IncomingMessage::new("signal", &sender, text).with_metadata(metadata);
|
||
|
||
// Use sourceName as display name if available.
|
||
if let Some(ref name) = envelope.source_name
|
||
&& !name.is_empty()
|
||
{
|
||
msg = msg.with_user_name(name);
|
||
}
|
||
|
||
// Use a deterministic UUID as thread_id for all conversations.
|
||
// This ensures DMs and groups continue the same thread AND work with
|
||
// maybe_hydrate_thread, enabling conversation history persistence.
|
||
// Priority: source_uuid > generated UUID from phone/group
|
||
if data_msg.group_info.is_some() {
|
||
// For groups, use the group ID to generate a deterministic UUID
|
||
msg = msg.with_thread(Self::thread_id_from_identifier(&target));
|
||
} else if let Some(ref uuid) = envelope.source_uuid {
|
||
// Privacy mode users already have a UUID
|
||
msg = msg.with_thread(uuid.clone());
|
||
} else {
|
||
// For regular DMs, generate a deterministic UUID from the phone number
|
||
msg = msg.with_thread(Self::thread_id_from_identifier(&sender));
|
||
}
|
||
|
||
Some((msg, target))
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl Channel for SignalChannel {
|
||
fn name(&self) -> &str {
|
||
"signal"
|
||
}
|
||
|
||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||
|
||
let config = self.config.clone();
|
||
let client = self.client.clone();
|
||
let reply_targets = Arc::clone(&self.reply_targets);
|
||
let debug_mode = Arc::clone(&self.debug_mode);
|
||
|
||
tokio::spawn(async move {
|
||
if let Err(e) = sse_listener(config, client, tx, reply_targets, debug_mode).await {
|
||
tracing::error!("Signal SSE listener exited with error: {e}");
|
||
}
|
||
});
|
||
|
||
// Log the URL with credentials redacted (if any).
|
||
let safe_url = Self::redact_url(&self.config.http_url);
|
||
tracing::info!(
|
||
url = %safe_url,
|
||
"Signal channel started"
|
||
);
|
||
|
||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||
}
|
||
|
||
async fn respond(
|
||
&self,
|
||
msg: &IncomingMessage,
|
||
response: OutgoingResponse,
|
||
) -> Result<(), ChannelError> {
|
||
// Resolve reply target from stored metadata.
|
||
let target_str = {
|
||
let targets = self.reply_targets.read().await;
|
||
targets.peek(&msg.id).cloned()
|
||
}
|
||
.or_else(|| {
|
||
// Fall back to metadata if not in the map.
|
||
msg.metadata
|
||
.get("signal_target")
|
||
.and_then(|v| v.as_str())
|
||
.map(String::from)
|
||
})
|
||
.unwrap_or_else(|| msg.user_id.clone());
|
||
|
||
let target = Self::parse_recipient_target(&target_str);
|
||
|
||
// Use shared helper for sending with attachments (includes validation)
|
||
let result = self
|
||
.send_with_attachments(&target, &response.content, &response.attachments)
|
||
.await;
|
||
|
||
// Clean up stored target regardless of success or failure.
|
||
self.reply_targets.write().await.pop(&msg.id);
|
||
|
||
result
|
||
}
|
||
|
||
async fn send_status(
|
||
&self,
|
||
status: StatusUpdate,
|
||
metadata: &serde_json::Value,
|
||
) -> Result<(), ChannelError> {
|
||
// Send typing indicator for thinking status.
|
||
if matches!(status, StatusUpdate::Thinking(_))
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let target = Self::parse_recipient_target(target_str);
|
||
let params = self.build_rpc_params(&target, None, None);
|
||
let _ = self.rpc_request("sendTyping", params).await;
|
||
}
|
||
|
||
// Send approval prompt to user
|
||
if let StatusUpdate::ApprovalNeeded {
|
||
request_id,
|
||
tool_name,
|
||
description: _,
|
||
parameters,
|
||
} = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
|
||
let message = format!(
|
||
"⚠️ *Approval Required*\n\n\
|
||
*Request ID:* `{}`\n\
|
||
*Tool:* {}\n\
|
||
*Parameters:*\n```\n{}\n```\n\n\
|
||
Reply with:\n\
|
||
• `yes` or `y` - Approve this request\n\
|
||
• `always` or `a` - Approve and auto-approve future {} requests\n\
|
||
• `no` or `n` - Deny",
|
||
request_id, tool_name, params_json, tool_name
|
||
);
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Filter out well-known UX/terminal status messages to avoid redundant updates.
|
||
let should_forward_status = |msg: &str| {
|
||
let normalized = msg.trim();
|
||
!normalized.eq_ignore_ascii_case("done")
|
||
&& !normalized.eq_ignore_ascii_case("awaiting approval")
|
||
&& !normalized.eq_ignore_ascii_case("rejected")
|
||
};
|
||
// Filter/send status messages
|
||
if let StatusUpdate::Status(msg) = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
&& should_forward_status(msg)
|
||
{
|
||
self.send_status_message(target_str, msg).await;
|
||
}
|
||
|
||
// Send tool result previews to user (debug mode only)
|
||
if self.is_debug()
|
||
&& let StatusUpdate::ToolResult { name, preview } = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let truncated = if preview.chars().count() > 500 {
|
||
let s: String = preview.chars().take(500).collect();
|
||
format!("{s}...")
|
||
} else {
|
||
preview.clone()
|
||
};
|
||
let message = format!("Tool '{}' result:\n{}", name, truncated);
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Send tool started notification (debug mode only)
|
||
if self.is_debug()
|
||
&& let StatusUpdate::ToolStarted { name } = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let message = format!("\u{25CB} Running tool: {}", name);
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Send tool completed notification (debug mode only)
|
||
if self.is_debug()
|
||
&& let StatusUpdate::ToolCompleted { name, success, .. } = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let (icon, color) = if *success {
|
||
("\u{25CF}", "success")
|
||
} else {
|
||
("\u{2717}", "failed")
|
||
};
|
||
let message = format!("{} Tool '{}' completed ({})", icon, name, color);
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Send job started notification (sandbox jobs)
|
||
if let StatusUpdate::JobStarted {
|
||
job_id,
|
||
title,
|
||
browse_url,
|
||
} = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let message = format!(
|
||
"\u{1F680} Job started: {}\nID: {}\nURL: {}",
|
||
title, job_id, browse_url
|
||
);
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Send auth required notification
|
||
if let StatusUpdate::AuthRequired {
|
||
extension_name,
|
||
instructions,
|
||
auth_url,
|
||
setup_url,
|
||
} = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let mut message = format!("\u{1F512} Authentication required for: {}", extension_name);
|
||
if let Some(instr) = instructions {
|
||
message.push_str(&format!("\n\n{}", instr));
|
||
}
|
||
if let Some(url) = auth_url {
|
||
message.push_str(&format!("\n\nAuth URL: {}", url));
|
||
}
|
||
if let Some(url) = setup_url {
|
||
message.push_str(&format!("\nSetup URL: {}", url));
|
||
}
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
// Send auth completed notification
|
||
if let StatusUpdate::AuthCompleted {
|
||
extension_name,
|
||
success,
|
||
message: msg,
|
||
} = &status
|
||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
{
|
||
let icon = if *success { "\u{2705}" } else { "\u{274C}" };
|
||
let mut message = format!(
|
||
"{} Authentication {} for {}",
|
||
icon,
|
||
if *success { "completed" } else { "failed" },
|
||
extension_name
|
||
);
|
||
if !msg.is_empty() {
|
||
message.push_str(&format!("\n{}", msg));
|
||
}
|
||
self.send_status_message(target_str, &message).await;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn broadcast(
|
||
&self,
|
||
user_id: &str,
|
||
response: OutgoingResponse,
|
||
) -> Result<(), ChannelError> {
|
||
let target = Self::parse_recipient_target(user_id);
|
||
|
||
// Use shared helper for sending with attachments (includes validation)
|
||
self.send_with_attachments(&target, &response.content, &response.attachments)
|
||
.await
|
||
}
|
||
|
||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||
let url = format!("{}{}", self.config.http_url, SIGNAL_HEALTH_ENDPOINT);
|
||
let resp = self
|
||
.client
|
||
.get(&url)
|
||
.timeout(Duration::from_secs(10))
|
||
.send()
|
||
.await
|
||
.map_err(|e| ChannelError::HealthCheckFailed {
|
||
name: format!("signal ({}): {e}", Self::redact_url(&url)),
|
||
})?;
|
||
|
||
if resp.status().is_success() {
|
||
Ok(())
|
||
} else {
|
||
Err(ChannelError::HealthCheckFailed {
|
||
name: format!("signal: HTTP {}", resp.status()),
|
||
})
|
||
}
|
||
}
|
||
|
||
fn conversation_context(
|
||
&self,
|
||
metadata: &serde_json::Value,
|
||
) -> std::collections::HashMap<String, String> {
|
||
use std::collections::HashMap;
|
||
let mut ctx = HashMap::new();
|
||
|
||
if let Some(sender) = metadata.get("signal_sender").and_then(|v| v.as_str()) {
|
||
ctx.insert("sender".to_string(), sender.to_string());
|
||
}
|
||
if let Some(sender_uuid) = metadata.get("signal_sender_uuid").and_then(|v| v.as_str()) {
|
||
ctx.insert("sender_uuid".to_string(), sender_uuid.to_string());
|
||
}
|
||
if let Some(target) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||
&& target.starts_with("group:")
|
||
{
|
||
ctx.insert("group".to_string(), target.to_string());
|
||
}
|
||
|
||
ctx
|
||
}
|
||
}
|
||
|
||
impl SignalChannel {
|
||
async fn send_status_message(&self, target: &str, message: &str) {
|
||
let target = Self::parse_recipient_target(target);
|
||
let params = self.build_rpc_params(&target, Some(message), None);
|
||
if let Err(e) = self.rpc_request("send", params).await {
|
||
tracing::warn!("Signal: failed to send status message: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Long-running SSE listener that reconnects with exponential backoff.
|
||
async fn sse_listener(
|
||
config: SignalConfig,
|
||
client: Client,
|
||
tx: tokio::sync::mpsc::Sender<IncomingMessage>,
|
||
reply_targets: Arc<RwLock<LruCache<Uuid, String>>>,
|
||
debug_mode: Arc<AtomicBool>,
|
||
) -> Result<(), ChannelError> {
|
||
let channel = SignalChannel::from_parts(
|
||
config,
|
||
client,
|
||
Arc::clone(&reply_targets),
|
||
Arc::clone(&debug_mode),
|
||
);
|
||
|
||
let mut url = reqwest::Url::parse(&format!("{}/api/v1/events", channel.config.http_url))
|
||
.map_err(|e| ChannelError::StartupFailed {
|
||
name: "signal".to_string(),
|
||
reason: format!("Invalid SSE URL: {e}"),
|
||
})?;
|
||
url.query_pairs_mut()
|
||
.append_pair("account", &channel.config.account);
|
||
|
||
let mut retry_delay = Duration::from_secs(2);
|
||
let max_delay = Duration::from_secs(60);
|
||
|
||
loop {
|
||
let resp = channel
|
||
.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 mut stream = r.bytes_stream();
|
||
let mut bytes = Vec::new();
|
||
let mut collected = 0usize;
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk = chunk.unwrap_or_default();
|
||
let remaining = MAX_ERROR_LOG_BODY.saturating_sub(collected);
|
||
if remaining == 0 {
|
||
break;
|
||
}
|
||
bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
||
collected = bytes.len();
|
||
if collected >= MAX_ERROR_LOG_BODY {
|
||
break;
|
||
}
|
||
}
|
||
let body = String::from_utf8_lossy(&bytes);
|
||
tracing::warn!("Signal SSE returned {status}: {body}");
|
||
tokio::time::sleep(retry_delay).await;
|
||
retry_delay = (retry_delay * 2).min(max_delay);
|
||
continue;
|
||
}
|
||
Err(e) => {
|
||
let safe_url = SignalChannel::redact_url(url.as_str());
|
||
tracing::warn!("Signal SSE connect error to {safe_url}: {e}, retrying...");
|
||
tokio::time::sleep(retry_delay).await;
|
||
retry_delay = (retry_delay * 2).min(max_delay);
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Connection succeeded — reset backoff.
|
||
retry_delay = Duration::from_secs(2);
|
||
tracing::info!("Signal SSE connected");
|
||
|
||
let mut bytes_stream = resp.bytes_stream();
|
||
let mut buffer = String::with_capacity(8192);
|
||
let mut current_data = String::with_capacity(4096);
|
||
// Holds trailing bytes from the previous chunk that form an incomplete
|
||
// multi-byte UTF-8 sequence. At most 3 bytes (the longest incomplete
|
||
// leading sequence for a 4-byte character).
|
||
let mut utf8_carry: Vec<u8> = Vec::with_capacity(4);
|
||
|
||
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;
|
||
}
|
||
};
|
||
|
||
// Prepend any leftover bytes from the previous chunk.
|
||
let decode_buf = if utf8_carry.is_empty() {
|
||
chunk.to_vec()
|
||
} else {
|
||
let mut combined = std::mem::take(&mut utf8_carry);
|
||
combined.extend_from_slice(&chunk);
|
||
combined
|
||
};
|
||
|
||
// Decode as much valid UTF-8 as possible, carrying over any
|
||
// incomplete trailing sequence to the next iteration.
|
||
let (valid_len, carry_start) = match std::str::from_utf8(&decode_buf) {
|
||
Ok(_) => (decode_buf.len(), decode_buf.len()),
|
||
Err(e) => {
|
||
let valid_up_to = e.valid_up_to();
|
||
match e.error_len() {
|
||
Some(bad_len) => {
|
||
// Genuinely invalid byte sequence (not just incomplete).
|
||
// Skip the bad byte(s) and keep going with what we have.
|
||
tracing::debug!(
|
||
"Signal SSE invalid UTF-8 byte at offset {valid_up_to}, \
|
||
skipping"
|
||
);
|
||
// Advance past the bad byte(s); remaining data (if any)
|
||
// will be carried over to the next chunk.
|
||
(valid_up_to, valid_up_to + bad_len)
|
||
}
|
||
None => {
|
||
// Incomplete multi-byte sequence at the end – carry it over.
|
||
(valid_up_to, valid_up_to)
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
use std::borrow::Cow;
|
||
|
||
debug_assert!(
|
||
std::str::from_utf8(&decode_buf[..valid_len]).is_ok(),
|
||
"valid_len {} should be a valid UTF-8 boundary (buffer len: {})",
|
||
valid_len,
|
||
decode_buf.len()
|
||
);
|
||
|
||
let text: Cow<str> = match std::str::from_utf8(&decode_buf[..valid_len]) {
|
||
Ok(s) => Cow::Borrowed(s),
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
"Signal SSE: unexpected invalid UTF-8 boundary at valid_len {}, \
|
||
falling back to lossy conversion",
|
||
valid_len
|
||
);
|
||
Cow::Owned(String::from_utf8_lossy(&decode_buf[..valid_len]).into_owned())
|
||
}
|
||
};
|
||
|
||
if buffer.len() + text.len() > MAX_SSE_BUFFER_SIZE {
|
||
tracing::warn!(
|
||
"Signal SSE buffer overflow, resetting: buffer_len={} text_len={} max={}",
|
||
buffer.len(),
|
||
text.len(),
|
||
MAX_SSE_BUFFER_SIZE
|
||
);
|
||
buffer.clear();
|
||
utf8_carry.clear();
|
||
current_data.clear();
|
||
continue;
|
||
}
|
||
buffer.push_str(&text);
|
||
|
||
// Preserve any trailing incomplete bytes for the next chunk.
|
||
if carry_start < decode_buf.len() {
|
||
utf8_carry.extend_from_slice(&decode_buf[carry_start..]);
|
||
}
|
||
|
||
while let Some(newline_pos) = buffer.find('\n') {
|
||
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
|
||
buffer.drain(..=newline_pos);
|
||
|
||
// 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
|
||
&& let Some((msg, target)) = channel.process_envelope(envelope)
|
||
{
|
||
// Handle /debug command locally (same as REPL).
|
||
let content_lower = msg.content.trim().to_lowercase();
|
||
if content_lower == "/debug" {
|
||
let new_state = channel.toggle_debug();
|
||
let response = if new_state {
|
||
"Debug mode enabled. Tool execution will be shown in chat."
|
||
} else {
|
||
"Debug mode disabled. Tool execution will be hidden from chat."
|
||
};
|
||
let reply_params = channel.build_rpc_params(
|
||
&SignalChannel::parse_recipient_target(&target),
|
||
Some(response),
|
||
None,
|
||
);
|
||
let _ = channel.rpc_request("send", reply_params).await;
|
||
// Don't send the /debug command to the agent.
|
||
continue;
|
||
}
|
||
|
||
// Store reply target for respond().
|
||
// LruCache automatically evicts the
|
||
// least-recently-used entry when full.
|
||
{
|
||
let mut targets = reply_targets.write().await;
|
||
targets.put(msg.id, target);
|
||
}
|
||
if tx.send(msg).await.is_err() {
|
||
tracing::debug!("Signal SSE: receiver dropped, exiting");
|
||
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.len() + data.len() > MAX_SSE_EVENT_SIZE {
|
||
tracing::warn!("Signal SSE event too large, dropping");
|
||
current_data.clear();
|
||
continue;
|
||
}
|
||
if !current_data.is_empty() {
|
||
current_data.push('\n');
|
||
}
|
||
current_data.push_str(data.trim_start());
|
||
}
|
||
// Ignore "event:", "id:", "retry:" lines.
|
||
}
|
||
}
|
||
|
||
// Process any trailing data before reconnect.
|
||
if !current_data.is_empty()
|
||
&& let Ok(sse) = serde_json::from_str::<SseEnvelope>(¤t_data)
|
||
&& let Some(ref envelope) = sse.envelope
|
||
&& let Some((msg, target)) = channel.process_envelope(envelope)
|
||
{
|
||
reply_targets.write().await.put(msg.id, target);
|
||
let _ = tx.send(msg).await;
|
||
}
|
||
|
||
tracing::debug!("Signal SSE stream ended, reconnecting with backoff...");
|
||
tokio::time::sleep(retry_delay).await;
|
||
retry_delay = std::cmp::min(retry_delay * 2, max_delay);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn make_config() -> SignalConfig {
|
||
SignalConfig {
|
||
http_url: "http://127.0.0.1:8686".to_string(),
|
||
account: "+1234567890".to_string(),
|
||
allow_from: vec!["+1111111111".to_string()],
|
||
allow_from_groups: vec![],
|
||
dm_policy: "allowlist".to_string(),
|
||
group_policy: "disabled".to_string(),
|
||
group_allow_from: vec![],
|
||
ignore_attachments: false,
|
||
ignore_stories: false,
|
||
}
|
||
}
|
||
|
||
/// Create a config that allows a specific group (and all senders).
|
||
fn make_config_with_allowed_group(group_id: &str) -> SignalConfig {
|
||
SignalConfig {
|
||
http_url: "http://127.0.0.1:8686".to_string(),
|
||
account: "+1234567890".to_string(),
|
||
allow_from: vec!["*".to_string()],
|
||
allow_from_groups: vec![group_id.to_string()],
|
||
dm_policy: "allowlist".to_string(),
|
||
group_policy: "allowlist".to_string(),
|
||
group_allow_from: vec![],
|
||
ignore_attachments: true,
|
||
ignore_stories: true,
|
||
}
|
||
}
|
||
|
||
fn make_channel() -> Result<SignalChannel, ChannelError> {
|
||
SignalChannel::new(make_config())
|
||
}
|
||
|
||
fn make_channel_with_allowed_group(group_id: &str) -> Result<SignalChannel, ChannelError> {
|
||
SignalChannel::new(make_config_with_allowed_group(group_id))
|
||
}
|
||
|
||
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),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: message.map(|m| DataMessage {
|
||
message: Some(m.to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn creates_with_correct_fields() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||
assert_eq!(ch.config.account, "+1234567890");
|
||
assert_eq!(ch.config.allow_from.len(), 1);
|
||
assert!(ch.config.allow_from_groups.is_empty());
|
||
assert!(!ch.config.ignore_attachments);
|
||
assert!(!ch.config.ignore_stories);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn strips_trailing_slash() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.http_url = "http://127.0.0.1:8686/".to_string();
|
||
let ch = SignalChannel::new(config)?;
|
||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn debug_mode_disabled_by_default() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
assert!(!ch.is_debug());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn debug_mode_toggle() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
|
||
// Initially disabled
|
||
assert!(!ch.is_debug());
|
||
|
||
// Toggle on
|
||
let new_state = ch.toggle_debug();
|
||
assert!(new_state);
|
||
assert!(ch.is_debug());
|
||
|
||
// Toggle off
|
||
let new_state = ch.toggle_debug();
|
||
assert!(!new_state);
|
||
assert!(!ch.is_debug());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn debug_mode_persists_across_toggles() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
|
||
// Multiple toggles
|
||
ch.toggle_debug();
|
||
assert!(ch.is_debug());
|
||
ch.toggle_debug();
|
||
assert!(!ch.is_debug());
|
||
ch.toggle_debug();
|
||
assert!(ch.is_debug());
|
||
ch.toggle_debug();
|
||
assert!(!ch.is_debug());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn wildcard_allows_anyone() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_sender_allowed("+9999999999"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn specific_sender_allowed() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
assert!(ch.is_sender_allowed("+1111111111"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_sender_denied() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
assert!(!ch.is_sender_allowed("+9999999999"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn empty_allowlist_denies_all() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec![];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(!ch.is_sender_allowed("+1111111111"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn uuid_prefix_in_allowlist() -> Result<(), ChannelError> {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let mut config = make_config();
|
||
config.allow_from = vec![format!("uuid:{uuid}")];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_sender_allowed(uuid));
|
||
// Should not match phone numbers.
|
||
assert!(!ch.is_sender_allowed("+1111111111"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn bare_uuid_in_allowlist() -> Result<(), ChannelError> {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let mut config = make_config();
|
||
config.allow_from = vec![uuid.to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_sender_allowed(uuid));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn group_allowlist_filtering() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.allow_from_groups = vec!["group123".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_group_allowed("group123"));
|
||
assert!(!ch.is_group_allowed("other_group"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn group_allowlist_wildcard() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from_groups = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_group_allowed("any_group"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn group_allowlist_empty_denies_all() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from_groups = vec![];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(!ch.is_group_allowed("any_group"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn name_returns_signal() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
assert_eq!(ch.name(), "signal");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_dm_accepted_with_empty_allow_from_groups() -> Result<(), ChannelError> {
|
||
// Empty allow_from_groups = DMs only. DMs should be accepted.
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some("Hello!"));
|
||
assert!(ch.process_envelope(&env).is_some());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_group_denied_with_empty_allow_from_groups() -> Result<(), ChannelError> {
|
||
// Empty allow_from_groups = DMs only. Group messages should be denied.
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: Some(1000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("group123".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1000),
|
||
};
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_group_accepted_when_in_allow_from_groups() -> Result<(), ChannelError> {
|
||
let ch = make_channel_with_allowed_group("group123")?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: Some(1000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("group123".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1000),
|
||
};
|
||
assert!(ch.process_envelope(&env).is_some());
|
||
|
||
// Different group should be denied.
|
||
let env2 = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: Some(1000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("other_group".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1000),
|
||
};
|
||
assert!(ch.process_envelope(&env2).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn reply_target_dm() {
|
||
let dm = DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: Some(1000),
|
||
group_info: None,
|
||
attachments: None,
|
||
};
|
||
assert_eq!(
|
||
SignalChannel::reply_target(&dm, "+1111111111"),
|
||
"+1111111111"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reply_target_group() {
|
||
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!(
|
||
SignalChannel::reply_target(&group, "+1111111111"),
|
||
"group:group123"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_recipient_target_e164_is_direct() {
|
||
assert_eq!(
|
||
SignalChannel::parse_recipient_target("+1234567890"),
|
||
RecipientTarget::Direct("+1234567890".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_recipient_target_prefixed_group_is_group() {
|
||
assert_eq!(
|
||
SignalChannel::parse_recipient_target("group:abc123"),
|
||
RecipientTarget::Group("abc123".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_recipient_target_uuid_is_direct() {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
assert_eq!(
|
||
SignalChannel::parse_recipient_target(uuid),
|
||
RecipientTarget::Direct(uuid.to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_recipient_target_non_e164_plus_is_group() {
|
||
assert_eq!(
|
||
SignalChannel::parse_recipient_target("+abc123"),
|
||
RecipientTarget::Group("+abc123".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn is_uuid_valid() {
|
||
assert!(SignalChannel::is_uuid(
|
||
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||
));
|
||
assert!(SignalChannel::is_uuid(
|
||
"00000000-0000-0000-0000-000000000000"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_uuid_invalid() {
|
||
assert!(!SignalChannel::is_uuid("+1234567890"));
|
||
assert!(!SignalChannel::is_uuid("not-a-uuid"));
|
||
assert!(!SignalChannel::is_uuid("group:abc123"));
|
||
assert!(!SignalChannel::is_uuid(""));
|
||
}
|
||
|
||
#[test]
|
||
fn thread_id_from_identifier_is_deterministic() {
|
||
let id1 = SignalChannel::thread_id_from_identifier("+1234567890");
|
||
let id2 = SignalChannel::thread_id_from_identifier("+1234567890");
|
||
assert_eq!(id1, id2, "same input should produce same UUID");
|
||
}
|
||
|
||
#[test]
|
||
fn thread_id_from_identifier_is_valid_uuid() {
|
||
let id = SignalChannel::thread_id_from_identifier("+1234567890");
|
||
assert!(Uuid::parse_str(&id).is_ok(), "should be a valid UUID");
|
||
}
|
||
|
||
#[test]
|
||
fn thread_id_from_identifier_different_inputs() {
|
||
let id1 = SignalChannel::thread_id_from_identifier("+1234567890");
|
||
let id2 = SignalChannel::thread_id_from_identifier("+9876543210");
|
||
assert_ne!(id1, id2, "different inputs should produce different UUIDs");
|
||
}
|
||
|
||
#[test]
|
||
fn sender_prefers_source_number() {
|
||
let env = Envelope {
|
||
source: Some("uuid-123".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
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("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
|
||
source_number: None,
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: None,
|
||
story_message: None,
|
||
timestamp: Some(1000),
|
||
};
|
||
assert_eq!(
|
||
SignalChannel::sender(&env),
|
||
Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sender_none_when_both_missing() {
|
||
let env = Envelope {
|
||
source: None,
|
||
source_number: None,
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: None,
|
||
story_message: None,
|
||
timestamp: None,
|
||
};
|
||
assert_eq!(SignalChannel::sender(&env), None);
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_valid_dm() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some("Hello!"));
|
||
let (msg, target) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.content, "Hello!");
|
||
assert_eq!(msg.user_id, "+1111111111");
|
||
assert_eq!(msg.channel, "signal");
|
||
assert_eq!(target, "+1111111111");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_denied_sender() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+9999999999"), Some("Hello!"));
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_empty_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some(""));
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_no_data_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), None);
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_skips_stories() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.ignore_stories = true;
|
||
let ch = SignalChannel::new(config)?;
|
||
let mut env = make_envelope(Some("+1111111111"), Some("story text"));
|
||
env.story_message = Some(serde_json::json!({}));
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_skips_attachment_only() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.ignore_attachments = true;
|
||
let ch = SignalChannel::new(config)?;
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]),
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_uuid_sender_dm() -> Result<(), ChannelError> {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some(uuid.to_string()),
|
||
source_number: None,
|
||
source_name: Some("Privacy User".to_string()),
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Hello from privacy user".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, target) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.user_id, uuid);
|
||
assert_eq!(msg.user_name.as_deref(), Some("Privacy User"));
|
||
assert_eq!(msg.content, "Hello from privacy user");
|
||
assert_eq!(target, uuid);
|
||
|
||
// Verify reply routing: UUID sender in DM should route as Direct.
|
||
let parsed = SignalChannel::parse_recipient_target(&target);
|
||
assert_eq!(parsed, RecipientTarget::Direct(uuid.to_string()));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_uuid_sender_in_group() -> Result<(), ChannelError> {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let mut config = make_config_with_allowed_group("testgroup");
|
||
config.ignore_attachments = false;
|
||
config.ignore_stories = false;
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some(uuid.to_string()),
|
||
source_number: None,
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Group msg from privacy user".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("testgroup".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, target) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.user_id, uuid);
|
||
assert_eq!(target, "group:testgroup");
|
||
// Groups now use deterministic UUID derived from group ID
|
||
let expected_thread_id = SignalChannel::thread_id_from_identifier("group:testgroup");
|
||
assert_eq!(msg.thread_id, Some(expected_thread_id));
|
||
|
||
// Verify reply routing: group message should still route as Group.
|
||
let parsed = SignalChannel::parse_recipient_target(&target);
|
||
assert_eq!(parsed, RecipientTarget::Group("testgroup".to_string()));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_group_not_in_allow_from_groups() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.allow_from_groups = vec!["allowed_group".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Hi".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("other_group".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
assert!(ch.process_envelope(&env).is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn sse_envelope_deserializes() {
|
||
let json = r#"{
|
||
"envelope": {
|
||
"source": "+1111111111",
|
||
"sourceNumber": "+1111111111",
|
||
"sourceName": "Test User",
|
||
"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"));
|
||
assert_eq!(env.source_name.as_deref(), Some("Test User"));
|
||
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.source_name.is_none());
|
||
assert!(env.data_message.is_none());
|
||
assert!(env.story_message.is_none());
|
||
assert!(env.timestamp.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_allow_entry_strips_uuid_prefix() {
|
||
assert_eq!(
|
||
SignalChannel::normalize_allow_entry("uuid:abc-123"),
|
||
"abc-123"
|
||
);
|
||
assert_eq!(
|
||
SignalChannel::normalize_allow_entry("+1234567890"),
|
||
"+1234567890"
|
||
);
|
||
assert_eq!(SignalChannel::normalize_allow_entry("*"), "*");
|
||
}
|
||
|
||
// ── build_rpc_params tests ──────────────────────────────────────
|
||
|
||
#[test]
|
||
fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||
let params = ch.build_rpc_params(&target, Some("Hello!"), None);
|
||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||
assert_eq!(params["account"], "+1234567890");
|
||
assert_eq!(params["message"], "Hello!");
|
||
// Direct targets must NOT include groupId.
|
||
assert!(params.get("groupId").is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||
let params = ch.build_rpc_params(&target, None, None);
|
||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||
assert_eq!(params["account"], "+1234567890");
|
||
// No message key should be present for typing indicators.
|
||
assert!(params.get("message").is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_group_with_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Group("abc123".to_string());
|
||
let params = ch.build_rpc_params(&target, Some("Group msg"), None);
|
||
assert_eq!(params["groupId"], "abc123");
|
||
assert_eq!(params["account"], "+1234567890");
|
||
assert_eq!(params["message"], "Group msg");
|
||
// Group targets must NOT include recipient.
|
||
assert!(params.get("recipient").is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_group_without_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Group("abc123".to_string());
|
||
let params = ch.build_rpc_params(&target, None, None);
|
||
assert_eq!(params["groupId"], "abc123");
|
||
assert_eq!(params["account"], "+1234567890");
|
||
assert!(params.get("message").is_none());
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_uuid_direct_target() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let target = RecipientTarget::Direct(uuid.to_string());
|
||
let params = ch.build_rpc_params(&target, Some("hi"), None);
|
||
assert_eq!(params["recipient"], serde_json::json!([uuid]));
|
||
Ok(())
|
||
}
|
||
|
||
// ── build_rpc_params with attachments tests ─────────────────────────
|
||
|
||
#[test]
|
||
fn build_rpc_params_with_attachments() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||
let attachments = vec!["/path/to/image.png".to_string()];
|
||
let params = ch.build_rpc_params(&target, Some("Check this!"), Some(&attachments));
|
||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||
assert_eq!(params["message"], "Check this!");
|
||
assert_eq!(
|
||
params["attachments"],
|
||
serde_json::json!(["/path/to/image.png"])
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_with_multiple_attachments() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||
let attachments = vec![
|
||
"/path/to/image.png".to_string(),
|
||
"/path/to/document.pdf".to_string(),
|
||
];
|
||
let params = ch.build_rpc_params(&target, Some("Files attached"), Some(&attachments));
|
||
assert_eq!(
|
||
params["attachments"],
|
||
serde_json::json!(["/path/to/image.png", "/path/to/document.pdf"])
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_with_attachments_no_message() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||
let attachments = vec!["/path/to/image.png".to_string()];
|
||
let params = ch.build_rpc_params(&target, None, Some(&attachments));
|
||
assert!(params.get("message").is_none());
|
||
assert_eq!(
|
||
params["attachments"],
|
||
serde_json::json!(["/path/to/image.png"])
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn build_rpc_params_group_with_attachments() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let target = RecipientTarget::Group("abc123".to_string());
|
||
let attachments = vec!["/path/to/photo.jpg".to_string()];
|
||
let params = ch.build_rpc_params(&target, Some("Group photo"), Some(&attachments));
|
||
assert_eq!(params["groupId"], "abc123");
|
||
assert_eq!(params["message"], "Group photo");
|
||
assert_eq!(
|
||
params["attachments"],
|
||
serde_json::json!(["/path/to/photo.jpg"])
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ── OutgoingResponse attachment tests ─────────────────────────────
|
||
|
||
#[test]
|
||
fn outgoing_response_with_attachments() {
|
||
let response = OutgoingResponse::text("Hello with file")
|
||
.with_attachments(vec!["/path/to/file.png".to_string()]);
|
||
assert_eq!(response.content, "Hello with file");
|
||
assert!(
|
||
response
|
||
.attachments
|
||
.contains(&"/path/to/file.png".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn outgoing_response_text_empty_attachments() {
|
||
let response = OutgoingResponse::text("Hello");
|
||
assert_eq!(response.content, "Hello");
|
||
assert!(response.attachments.is_empty());
|
||
}
|
||
|
||
// ── metadata assertion tests ────────────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_metadata_has_signal_fields() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some("Hello!"));
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.metadata["signal_sender"], "+1111111111");
|
||
assert_eq!(msg.metadata["signal_target"], "+1111111111");
|
||
assert_eq!(msg.metadata["signal_timestamp"], 1_700_000_000_000_u64);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_metadata_group_target() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.allow_from_groups = vec!["*".to_string()];
|
||
config.group_policy = "allowlist".to_string();
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+2222222222".to_string()),
|
||
source_number: Some("+2222222222".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("In the group".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("mygroup".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.metadata["signal_target"], "group:mygroup");
|
||
assert_eq!(msg.metadata["signal_sender"], "+2222222222");
|
||
Ok(())
|
||
}
|
||
|
||
// ── attachment-with-text tests ──────────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_attachment_with_text_not_skipped() -> Result<(), ChannelError> {
|
||
// Even with ignore_attachments=true, messages that have BOTH text
|
||
// and attachments should be processed (only attachment-only are skipped).
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.ignore_attachments = true;
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Check this out".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]),
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let result = ch.process_envelope(&env);
|
||
assert!(
|
||
result.is_some(),
|
||
"Message with text + attachment should not be skipped"
|
||
);
|
||
let (msg, _) = result.unwrap();
|
||
assert_eq!(msg.content, "Check this out");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_attachment_only_not_skipped_when_ignore_disabled()
|
||
-> Result<(), ChannelError> {
|
||
// With ignore_attachments=false, attachment-only messages should be
|
||
// processed with the "[Attachment]" placeholder text.
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.ignore_attachments = false;
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]),
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
// With ignore_attachments=false, attachment-only messages are now
|
||
// processed with a placeholder "[Attachment]" text.
|
||
let result = ch.process_envelope(&env);
|
||
assert!(
|
||
result.is_some(),
|
||
"Attachment-only should be processed when ignore_attachments=false"
|
||
);
|
||
let (msg, _) = result.unwrap();
|
||
assert_eq!(msg.content, "[Attachment]");
|
||
Ok(())
|
||
}
|
||
|
||
// ── source_name / display name tests ────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_source_name_sets_user_name() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+3333333333".to_string()),
|
||
source_number: Some("+3333333333".to_string()),
|
||
source_name: Some("Alice".to_string()),
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Hey".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.user_name.as_deref(), Some("Alice"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_empty_source_name_not_set() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+3333333333".to_string()),
|
||
source_number: Some("+3333333333".to_string()),
|
||
source_name: Some("".to_string()),
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Hey".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert!(
|
||
msg.user_name.is_none(),
|
||
"Empty source_name should not set user_name"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_no_source_name_not_set() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some("hi"));
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert!(msg.user_name.is_none());
|
||
Ok(())
|
||
}
|
||
|
||
// ── thread_id tests ─────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_dm_sets_thread_id_to_uuid() -> Result<(), ChannelError> {
|
||
let ch = make_channel()?;
|
||
let env = make_envelope(Some("+1111111111"), Some("DM"));
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
// DMs now set thread_id to a deterministic UUID derived from phone number
|
||
let expected_thread_id = SignalChannel::thread_id_from_identifier("+1111111111");
|
||
assert_eq!(
|
||
msg.thread_id,
|
||
Some(expected_thread_id),
|
||
"DMs should set thread_id to UUID"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_group_sets_thread_id_to_uuid() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.allow_from_groups = vec!["*".to_string()];
|
||
config.group_policy = "allowlist".to_string();
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("Group msg".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: Some(GroupInfo {
|
||
group_id: Some("grp999".to_string()),
|
||
}),
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
// Groups now set thread_id to a deterministic UUID derived from group ID
|
||
let expected_thread_id = SignalChannel::thread_id_from_identifier("group:grp999");
|
||
assert_eq!(
|
||
msg.thread_id,
|
||
Some(expected_thread_id),
|
||
"Groups should set thread_id to UUID"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ── timestamp edge cases ────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_uses_data_message_timestamp() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: Some(9999),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(1111),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
// data_message timestamp takes priority.
|
||
assert_eq!(msg.metadata["signal_timestamp"], 9999);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_falls_back_to_envelope_timestamp() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: None,
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: Some(7777),
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
assert_eq!(msg.metadata["signal_timestamp"], 7777);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn process_envelope_generates_timestamp_when_missing() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("hi".to_string()),
|
||
timestamp: None,
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: None,
|
||
timestamp: None,
|
||
};
|
||
let (msg, _) = ch.process_envelope(&env).unwrap();
|
||
// Should generate a timestamp (current time in millis), just verify it's positive.
|
||
let ts = msg.metadata["signal_timestamp"].as_u64().unwrap();
|
||
assert!(ts > 0, "Generated timestamp should be positive");
|
||
Ok(())
|
||
}
|
||
|
||
// ── SSE envelope deserialization edge cases ─────────────────────
|
||
|
||
#[test]
|
||
fn sse_envelope_missing_envelope_field() {
|
||
let json = r#"{"account": "+1234567890"}"#;
|
||
let sse: SseEnvelope = serde_json::from_str(json).unwrap();
|
||
assert!(sse.envelope.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn sse_envelope_with_story_message() {
|
||
let json = r#"{
|
||
"envelope": {
|
||
"sourceNumber": "+1111111111",
|
||
"storyMessage": {"allowsReplies": true},
|
||
"dataMessage": {
|
||
"message": "story text"
|
||
}
|
||
}
|
||
}"#;
|
||
let sse: SseEnvelope = serde_json::from_str(json).unwrap();
|
||
let env = sse.envelope.unwrap();
|
||
assert!(env.story_message.is_some());
|
||
assert!(env.data_message.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn sse_envelope_with_attachments() {
|
||
let json = r#"{
|
||
"envelope": {
|
||
"sourceNumber": "+1111111111",
|
||
"dataMessage": {
|
||
"message": "See attached",
|
||
"attachments": [
|
||
{"contentType": "image/jpeg", "filename": "photo.jpg"},
|
||
{"contentType": "application/pdf"}
|
||
]
|
||
}
|
||
}
|
||
}"#;
|
||
let sse: SseEnvelope = serde_json::from_str(json).unwrap();
|
||
let dm = sse.envelope.unwrap().data_message.unwrap();
|
||
let attachments = dm.attachments.unwrap();
|
||
assert_eq!(attachments.len(), 2);
|
||
}
|
||
|
||
// ── is_e164 tests ───────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn is_e164_valid_numbers() {
|
||
assert!(SignalChannel::is_e164("+12345678901"));
|
||
assert!(SignalChannel::is_e164("+1234567")); // min 7 digits after +
|
||
assert!(SignalChannel::is_e164("+123456789012345")); // max 15 digits
|
||
}
|
||
|
||
#[test]
|
||
fn is_e164_invalid_numbers() {
|
||
assert!(!SignalChannel::is_e164("12345678901")); // no +
|
||
assert!(!SignalChannel::is_e164("+1")); // too short (1 digit)
|
||
assert!(!SignalChannel::is_e164("+1234567890123456")); // too long (16 digits)
|
||
assert!(!SignalChannel::is_e164("+abc123")); // non-digit
|
||
assert!(!SignalChannel::is_e164("")); // empty
|
||
assert!(!SignalChannel::is_e164("+")); // plus only
|
||
}
|
||
|
||
// ── config edge cases ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn multiple_allow_from() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from = vec![
|
||
"+1111111111".to_string(),
|
||
"+2222222222".to_string(),
|
||
"a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(),
|
||
];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_sender_allowed("+1111111111"));
|
||
assert!(ch.is_sender_allowed("+2222222222"));
|
||
assert!(ch.is_sender_allowed("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
|
||
assert!(!ch.is_sender_allowed("+9999999999"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_allow_from_groups() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.allow_from_groups = vec!["group_a".to_string(), "group_b".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
assert!(ch.is_group_allowed("group_a"));
|
||
assert!(ch.is_group_allowed("group_b"));
|
||
assert!(!ch.is_group_allowed("group_c"));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn uuid_prefix_normalization_in_allowlist() -> Result<(), ChannelError> {
|
||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||
let mut config = make_config();
|
||
config.allow_from = vec![format!("uuid:{uuid}"), "+1111111111".to_string()];
|
||
let ch = SignalChannel::new(config)?;
|
||
// uuid:-prefixed entry should match bare UUID sender.
|
||
assert!(ch.is_sender_allowed(uuid));
|
||
// Phone numbers still work alongside UUID entries.
|
||
assert!(ch.is_sender_allowed("+1111111111"));
|
||
// Non-matching should fail.
|
||
assert!(!ch.is_sender_allowed("+9999999999"));
|
||
Ok(())
|
||
}
|
||
|
||
// ── stories behavior tests ──────────────────────────────────────
|
||
|
||
#[test]
|
||
fn process_envelope_stories_not_skipped_when_disabled() -> Result<(), ChannelError> {
|
||
// With ignore_stories=false, story messages with a data_message
|
||
// should still be processed.
|
||
let mut config = make_config();
|
||
config.allow_from = vec!["*".to_string()];
|
||
config.ignore_stories = false;
|
||
let ch = SignalChannel::new(config)?;
|
||
|
||
let env = Envelope {
|
||
source: Some("+1111111111".to_string()),
|
||
source_number: Some("+1111111111".to_string()),
|
||
source_name: None,
|
||
source_uuid: None,
|
||
data_message: Some(DataMessage {
|
||
message: Some("story with text".to_string()),
|
||
timestamp: Some(1_700_000_000_000),
|
||
group_info: None,
|
||
attachments: None,
|
||
}),
|
||
story_message: Some(serde_json::json!({})),
|
||
timestamp: Some(1_700_000_000_000),
|
||
};
|
||
let result = ch.process_envelope(&env);
|
||
assert!(
|
||
result.is_some(),
|
||
"Stories should not be skipped when ignore_stories=false"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ── trailing slash variations ───────────────────────────────────
|
||
|
||
#[test]
|
||
fn strips_multiple_trailing_slashes() -> Result<(), ChannelError> {
|
||
let mut config = make_config();
|
||
config.http_url = "http://127.0.0.1:8686///".to_string();
|
||
let ch = SignalChannel::new(config)?;
|
||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn preserves_url_without_trailing_slash() -> Result<(), ChannelError> {
|
||
let config = make_config();
|
||
let ch = SignalChannel::new(config)?;
|
||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||
Ok(())
|
||
}
|
||
|
||
// ── attachment path validation ───────────────────────────────────
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_rejects_double_dot() {
|
||
let paths = vec!["../etc/passwd".to_string()];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err().to_string();
|
||
assert!(err.contains("forbidden") || err.contains("sandbox"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_accepts_normal_paths() {
|
||
use std::fs;
|
||
|
||
// Create test files in sandbox
|
||
let base_dir = crate::bootstrap::ironclaw_base_dir();
|
||
|
||
// Create sandbox directory if it doesn't exist (needed for CI)
|
||
let _ = fs::create_dir_all(&base_dir);
|
||
|
||
let temp_dir = tempfile::tempdir_in(&base_dir).unwrap();
|
||
let file1 = temp_dir.path().join("file.txt");
|
||
let file2 = temp_dir.path().join("report.pdf");
|
||
fs::write(&file1, "test").unwrap();
|
||
fs::write(&file2, "test").unwrap();
|
||
|
||
let paths = vec![
|
||
file1.to_string_lossy().to_string(),
|
||
file2.to_string_lossy().to_string(),
|
||
];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_rejects_nested_traversal() {
|
||
let paths = vec!["foo/../bar/../../secret.txt".to_string()];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_empty_ok() {
|
||
let paths: Vec<String> = vec![];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_rejects_path_outside_sandbox() {
|
||
let paths = vec!["/tmp/evil.txt".to_string()];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err().to_string();
|
||
assert!(err.contains("sandbox"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_rejects_url_encoded_traversal() {
|
||
let paths = vec!["%2e%2e%2fetc/passwd".to_string()];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_attachment_paths_rejects_null_byte() {
|
||
let paths = vec!["file\0.txt".to_string()];
|
||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
// ── conversation context ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn conversation_context_extracts_sender() {
|
||
let ch = SignalChannel::new(make_config()).unwrap();
|
||
let metadata = serde_json::json!({
|
||
"signal_sender": "+1234567890",
|
||
"signal_sender_uuid": "uuid-123",
|
||
"signal_target": "+0987654321"
|
||
});
|
||
let ctx = ch.conversation_context(&metadata);
|
||
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
|
||
assert_eq!(ctx.get("sender_uuid"), Some(&"uuid-123".to_string()));
|
||
assert!(!ctx.contains_key("group"));
|
||
}
|
||
|
||
#[test]
|
||
fn conversation_context_extracts_group() {
|
||
let ch = SignalChannel::new(make_config()).unwrap();
|
||
let metadata = serde_json::json!({
|
||
"signal_sender": "+1234567890",
|
||
"signal_target": "group:mygroup"
|
||
});
|
||
let ctx = ch.conversation_context(&metadata);
|
||
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
|
||
assert_eq!(ctx.get("group"), Some(&"group:mygroup".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn conversation_context_empty_for_unknown_channel() {
|
||
let ch = SignalChannel::new(make_config()).unwrap();
|
||
let metadata = serde_json::json!({
|
||
"unknown_key": "value"
|
||
});
|
||
let ctx = ch.conversation_context(&metadata);
|
||
assert!(ctx.is_empty());
|
||
}
|
||
}
|