Skip to main content

zeroclaw_channels/
signal.rs

1use async_trait::async_trait;
2use futures_util::StreamExt;
3use lru::LruCache;
4use parking_lot::Mutex as SyncMutex;
5use reqwest::Client;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::num::NonZeroUsize;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{Mutex, mpsc, oneshot};
12use uuid::Uuid;
13use zeroclaw_api::channel::{
14    Channel, ChannelApprovalRequest, ChannelApprovalResponse, ChannelMessage, SendMessage,
15};
16
17const GROUP_TARGET_PREFIX: &str = "group:";
18
19/// How many recent inbound messages we remember for the purpose of
20/// addressing outbound reactions back at them. signal-cli's `sendReaction`
21/// is keyed on `(targetAuthor, targetTimestamp)`, but we don't want those
22/// values to leak into the generic `ChannelMessage.id` (which flows into
23/// logs, memory keys, thread roots, and tool args). Instead we mint an
24/// opaque id and remember the mapping channel-locally.
25const RECENT_TARGETS_CAPACITY: usize = 1024;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28enum RecipientTarget {
29    Direct(String),
30    Group(String),
31}
32
33/// `(targetAuthor, targetTimestamp_ms)` recovered by `add_reaction` /
34/// `remove_reaction` from an opaque inbound id. Held in `recent_targets`.
35#[derive(Debug, Clone)]
36struct ReactionTarget {
37    author: String,
38    timestamp_ms: u64,
39}
40
41/// Signal channel using signal-cli daemon's native JSON-RPC + SSE API.
42///
43/// Connects to a running `signal-cli daemon --http <host:port>`.
44/// Listens via SSE at `/api/v1/events` and sends via JSON-RPC at
45/// `/api/v1/rpc`.
46#[derive(Clone)]
47pub struct SignalChannel {
48    http_url: String,
49    account: String,
50    /// Empty = no group filter (all groups accepted).
51    group_ids: Vec<String>,
52    /// When true, accept only DMs and reject all group traffic.
53    dm_only: bool,
54    /// The alias key under `[channels.signal.<alias>]` this handle is
55    /// bound to. Used to scope peer-group writes and resolver lookups.
56    alias: String,
57    /// Resolves inbound external peers from canonical state at message-time.
58    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
59    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
60    ignore_attachments: bool,
61    ignore_stories: bool,
62    /// Per-channel proxy URL override.
63    proxy_url: Option<String>,
64    pending_approvals: Arc<Mutex<HashMap<String, oneshot::Sender<ChannelApprovalResponse>>>>,
65    /// Seconds to wait for an operator reply to a `request_approval` prompt
66    /// before treating the silence as a deny. Default 300.
67    approval_timeout_secs: u64,
68    /// Opaque inbound message id → `(targetAuthor, targetTimestamp)` so
69    /// outbound reactions can be addressed without embedding the Signal
70    /// sender (E.164 phone number or UUID) in `ChannelMessage.id`. Bounded
71    /// LRU; once a message ages out, reactions against it fail cleanly.
72    recent_targets: Arc<SyncMutex<LruCache<String, ReactionTarget>>>,
73}
74
75// ── signal-cli SSE event JSON shapes ────────────────────────────
76
77#[derive(Debug, Deserialize)]
78struct SseEnvelope {
79    #[serde(default)]
80    envelope: Option<Envelope>,
81}
82
83#[derive(Debug, Deserialize)]
84struct Envelope {
85    #[serde(default)]
86    source: Option<String>,
87    #[serde(rename = "sourceNumber", default)]
88    source_number: Option<String>,
89    #[serde(rename = "dataMessage", default)]
90    data_message: Option<DataMessage>,
91    #[serde(rename = "storyMessage", default)]
92    story_message: Option<serde_json::Value>,
93    #[serde(default)]
94    timestamp: Option<u64>,
95}
96
97#[derive(Debug, Deserialize)]
98struct DataMessage {
99    #[serde(default)]
100    message: Option<String>,
101    #[serde(default)]
102    timestamp: Option<u64>,
103    #[serde(rename = "groupInfo", default)]
104    group_info: Option<GroupInfo>,
105    #[serde(default)]
106    attachments: Option<Vec<serde_json::Value>>,
107}
108
109#[derive(Debug, Deserialize)]
110struct GroupInfo {
111    #[serde(rename = "groupId", default)]
112    group_id: Option<String>,
113}
114
115impl SignalChannel {
116    pub fn new(
117        http_url: String,
118        account: String,
119        group_ids: Vec<String>,
120        dm_only: bool,
121        alias: impl Into<String>,
122        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
123        ignore_attachments: bool,
124        ignore_stories: bool,
125    ) -> Self {
126        let http_url = http_url.trim_end_matches('/').to_string();
127        Self {
128            http_url,
129            account,
130            group_ids,
131            dm_only,
132            alias: alias.into(),
133            peer_resolver,
134            ignore_attachments,
135            ignore_stories,
136            proxy_url: None,
137            pending_approvals: Arc::new(Mutex::new(HashMap::new())),
138            approval_timeout_secs: 300,
139            recent_targets: Arc::new(SyncMutex::new(LruCache::new(
140                NonZeroUsize::new(RECENT_TARGETS_CAPACITY)
141                    .expect("RECENT_TARGETS_CAPACITY is a non-zero constant"),
142            ))),
143        }
144    }
145
146    /// Return the alias under `[channels.signal.<alias>]` that this
147    /// channel handle is bound to.
148    pub fn alias(&self) -> &str {
149        &self.alias
150    }
151
152    /// Set a per-channel proxy URL that overrides the global proxy config.
153    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
154        self.proxy_url = proxy_url;
155        self
156    }
157
158    pub fn with_approval_timeout_secs(mut self, secs: u64) -> Self {
159        self.approval_timeout_secs = secs;
160        self
161    }
162
163    fn http_client(&self) -> Client {
164        let builder = Client::builder().connect_timeout(Duration::from_secs(10));
165        let builder = zeroclaw_config::schema::apply_channel_proxy_to_builder(
166            builder,
167            "channel.signal",
168            self.proxy_url.as_deref(),
169        );
170        builder.build().expect("Signal HTTP client should build")
171    }
172
173    /// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`.
174    fn sender(envelope: &Envelope) -> Option<String> {
175        envelope
176            .source_number
177            .as_deref()
178            .or(envelope.source.as_deref())
179            .map(String::from)
180    }
181
182    fn is_sender_allowed(&self, sender: &str) -> bool {
183        let peers = (self.peer_resolver)();
184        crate::allowlist::is_user_allowed(&peers, sender, crate::allowlist::Match::Sensitive)
185    }
186
187    fn is_e164(recipient: &str) -> bool {
188        let Some(number) = recipient.strip_prefix('+') else {
189            return false;
190        };
191        (2..=15).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit())
192    }
193
194    /// Check whether a string is a valid UUID (signal-cli uses these for
195    /// privacy-enabled users who have opted out of sharing their phone number).
196    fn is_uuid(s: &str) -> bool {
197        Uuid::parse_str(s).is_ok()
198    }
199
200    fn parse_recipient_target(recipient: &str) -> RecipientTarget {
201        if let Some(group_id) = recipient.strip_prefix(GROUP_TARGET_PREFIX) {
202            return RecipientTarget::Group(group_id.to_string());
203        }
204
205        if Self::is_e164(recipient) || Self::is_uuid(recipient) {
206            RecipientTarget::Direct(recipient.to_string())
207        } else {
208            RecipientTarget::Group(recipient.to_string())
209        }
210    }
211
212    /// Build the JSON-RPC params for signal-cli's `sendReaction` method.
213    ///
214    /// `targetAuthor` and `targetTimestamp` are recovered from
215    /// `recent_targets` rather than parsed out of `message_id`, so the
216    /// generic id stays opaque and the Signal sender never leaks into
217    /// the surfaces that consume `ChannelMessage.id`.
218    ///
219    /// Extracted from `add_reaction` / `remove_reaction` so the wire
220    /// shape is unit-testable without a live daemon.
221    fn build_reaction_params(
222        &self,
223        channel_id: &str,
224        message_id: &str,
225        emoji: &str,
226        remove: bool,
227    ) -> anyhow::Result<serde_json::Value> {
228        let target = self.recent_targets.lock().get(message_id).cloned().ok_or_else(|| {
229            anyhow::Error::msg(format!(
230                "no recent inbound Signal message matches id {message_id} — may have been evicted from the lookup cache or never received"
231            ))
232        })?;
233
234        let params = match Self::parse_recipient_target(channel_id) {
235            RecipientTarget::Direct(number) => serde_json::json!({
236                "recipient": [number],
237                "emoji": emoji,
238                "targetAuthor": target.author,
239                "targetTimestamp": target.timestamp_ms,
240                "remove": remove,
241                "account": &self.account,
242            }),
243            RecipientTarget::Group(group_id) => serde_json::json!({
244                "groupId": group_id,
245                "emoji": emoji,
246                "targetAuthor": target.author,
247                "targetTimestamp": target.timestamp_ms,
248                "remove": remove,
249                "account": &self.account,
250            }),
251        };
252
253        Ok(params)
254    }
255
256    /// Check whether the message passes the group/DM filter.
257    ///
258    /// - `dm_only = true`: only DMs accepted; all group messages rejected.
259    /// - `dm_only = false`, `group_ids` empty: accept all (DMs and any group).
260    /// - `dm_only = false`, `group_ids` non-empty: accept DMs and listed
261    ///   groups only.
262    fn matches_group(&self, data_msg: &DataMessage) -> bool {
263        let incoming_group = data_msg
264            .group_info
265            .as_ref()
266            .and_then(|g| g.group_id.as_deref());
267
268        if self.dm_only {
269            return incoming_group.is_none();
270        }
271
272        if self.group_ids.is_empty() {
273            return true;
274        }
275
276        match incoming_group {
277            Some(gid) => self.group_ids.iter().any(|allowed| allowed == gid),
278            None => true,
279        }
280    }
281
282    /// Determine the send target: group id or the sender's number.
283    fn reply_target(&self, data_msg: &DataMessage, sender: &str) -> String {
284        if let Some(group_id) = data_msg
285            .group_info
286            .as_ref()
287            .and_then(|g| g.group_id.as_deref())
288        {
289            format!("{GROUP_TARGET_PREFIX}{group_id}")
290        } else {
291            sender.to_string()
292        }
293    }
294
295    /// Send a JSON-RPC request to signal-cli daemon.
296    async fn rpc_request(
297        &self,
298        method: &str,
299        params: serde_json::Value,
300    ) -> anyhow::Result<Option<serde_json::Value>> {
301        let url = format!("{}/api/v1/rpc", self.http_url);
302        let id = Uuid::new_v4().to_string();
303
304        let body = serde_json::json!({
305            "jsonrpc": "2.0",
306            "method": method,
307            "params": params,
308            "id": id,
309        });
310
311        let resp = self
312            .http_client()
313            .post(&url)
314            .timeout(Duration::from_secs(30))
315            .header("Content-Type", "application/json")
316            .json(&body)
317            .send()
318            .await?;
319
320        // 201 = success with no body (e.g. typing indicators)
321        if resp.status().as_u16() == 201 {
322            return Ok(None);
323        }
324
325        let text = resp.text().await?;
326        if text.is_empty() {
327            return Ok(None);
328        }
329
330        let parsed: serde_json::Value = serde_json::from_str(&text)?;
331        if let Some(err) = parsed.get("error") {
332            let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
333            let msg = err
334                .get("message")
335                .and_then(|m| m.as_str())
336                .unwrap_or("unknown");
337            anyhow::bail!("Signal RPC error {code}: {msg}");
338        }
339
340        Ok(parsed.get("result").cloned())
341    }
342
343    /// Process a single SSE envelope, returning a ChannelMessage if valid.
344    fn process_envelope(&self, envelope: &Envelope) -> Option<ChannelMessage> {
345        // Skip story messages when configured
346        if self.ignore_stories && envelope.story_message.is_some() {
347            return None;
348        }
349
350        let data_msg = envelope.data_message.as_ref()?;
351
352        // Skip attachment-only messages when configured
353        if self.ignore_attachments {
354            let has_attachments = data_msg.attachments.as_ref().is_some_and(|a| !a.is_empty());
355            if has_attachments && data_msg.message.is_none() {
356                return None;
357            }
358        }
359
360        let text = data_msg.message.as_deref().filter(|t| !t.is_empty())?;
361        let sender = Self::sender(envelope)?;
362
363        if !self.is_sender_allowed(&sender) {
364            return None;
365        }
366
367        if !self.matches_group(data_msg) {
368            return None;
369        }
370
371        let target = self.reply_target(data_msg, &sender);
372
373        let timestamp = data_msg
374            .timestamp
375            .or(envelope.timestamp)
376            .unwrap_or_else(|| {
377                u64::try_from(
378                    std::time::SystemTime::now()
379                        .duration_since(std::time::UNIX_EPOCH)
380                        .unwrap_or_default()
381                        .as_millis(),
382                )
383                .unwrap_or(u64::MAX)
384            });
385
386        // Opaque id: timestamp is convenient for debugging, the random
387        // suffix disambiguates two senders that happen to post at the same
388        // millisecond in a group. Crucially, neither component reveals the
389        // sender — that lives only in the channel-local `recent_targets`
390        // map and the `sender` field on `ChannelMessage`.
391        let id = format!("sig_{timestamp}_{}", Self::random_id_suffix());
392        self.recent_targets.lock().put(
393            id.clone(),
394            ReactionTarget {
395                author: sender.clone(),
396                timestamp_ms: timestamp,
397            },
398        );
399
400        Some(ChannelMessage {
401            id,
402            sender: sender.clone(),
403            reply_target: target,
404            content: text.to_string(),
405            channel: "signal".to_string(),
406            channel_alias: Some(self.alias.clone()),
407            timestamp: timestamp / 1000, // millis → secs
408            thread_ts: None,
409            interruption_scope_id: None,
410            attachments: vec![],
411            subject: None,
412        })
413    }
414
415    fn random_id_suffix() -> String {
416        use rand::RngExt;
417        const CHARSET: &[u8] = b"0123456789abcdef";
418        let mut rng = rand::rng();
419        (0..6)
420            .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
421            .collect()
422    }
423}
424
425impl ::zeroclaw_api::attribution::Attributable for SignalChannel {
426    fn role(&self) -> ::zeroclaw_api::attribution::Role {
427        ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Signal)
428    }
429    fn alias(&self) -> &str {
430        &self.alias
431    }
432}
433
434#[async_trait]
435impl Channel for SignalChannel {
436    fn name(&self) -> &str {
437        "signal"
438    }
439
440    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
441        let params = match Self::parse_recipient_target(&message.recipient) {
442            RecipientTarget::Direct(number) => serde_json::json!({
443                "recipient": [number],
444                "message": &message.content,
445                "account": &self.account,
446            }),
447            RecipientTarget::Group(group_id) => serde_json::json!({
448                "groupId": group_id,
449                "message": &message.content,
450                "account": &self.account,
451            }),
452        };
453
454        self.rpc_request("send", params).await?;
455        Ok(())
456    }
457
458    async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
459        let mut url = reqwest::Url::parse(&format!("{}/api/v1/events", self.http_url))?;
460        url.query_pairs_mut().append_pair("account", &self.account);
461
462        ::zeroclaw_log::record!(
463            INFO,
464            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
465            &format!("channel listening via SSE on {}...", self.http_url)
466        );
467
468        let mut retry_delay_secs = 2u64;
469        let max_delay_secs = 60u64;
470
471        loop {
472            let resp = self
473                .http_client()
474                .get(url.clone())
475                .header("Accept", "text/event-stream")
476                .send()
477                .await;
478
479            let resp = match resp {
480                Ok(r) if r.status().is_success() => r,
481                Ok(r) => {
482                    let status = r.status();
483                    let body = r.text().await.unwrap_or_default();
484                    ::zeroclaw_log::record!(
485                        WARN,
486                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
487                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
488                            .with_attrs(
489                                ::serde_json::json!({"status": status.to_string(), "body": body})
490                            ),
491                        "SSE returned"
492                    );
493                    tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await;
494                    retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs);
495                    continue;
496                }
497                Err(e) => {
498                    ::zeroclaw_log::record!(
499                        WARN,
500                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
501                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
502                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
503                        "SSE connect error, retrying..."
504                    );
505                    tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await;
506                    retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs);
507                    continue;
508                }
509            };
510
511            retry_delay_secs = 2;
512
513            let mut bytes_stream = resp.bytes_stream();
514            let mut buffer = String::new();
515            let mut current_data = String::new();
516
517            while let Some(chunk) = bytes_stream.next().await {
518                let chunk = match chunk {
519                    Ok(c) => c,
520                    Err(e) => {
521                        ::zeroclaw_log::record!(
522                            DEBUG,
523                            ::zeroclaw_log::Event::new(
524                                module_path!(),
525                                ::zeroclaw_log::Action::Note
526                            )
527                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
528                            "SSE chunk error, reconnecting"
529                        );
530                        break;
531                    }
532                };
533
534                let text = match String::from_utf8(chunk.to_vec()) {
535                    Ok(t) => t,
536                    Err(e) => {
537                        ::zeroclaw_log::record!(
538                            DEBUG,
539                            ::zeroclaw_log::Event::new(
540                                module_path!(),
541                                ::zeroclaw_log::Action::Note
542                            )
543                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
544                            "SSE invalid UTF-8, skipping chunk"
545                        );
546                        continue;
547                    }
548                };
549
550                buffer.push_str(&text);
551
552                while let Some(newline_pos) = buffer.find('\n') {
553                    let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
554                    buffer = buffer[newline_pos + 1..].to_string();
555
556                    // Skip SSE comments (keepalive)
557                    if line.starts_with(':') {
558                        continue;
559                    }
560
561                    if line.is_empty() {
562                        // Empty line = event boundary, dispatch accumulated data
563                        if !current_data.is_empty() {
564                            match serde_json::from_str::<SseEnvelope>(&current_data) {
565                                Ok(sse) => {
566                                    if let Some(ref envelope) = sse.envelope
567                                        && let Some(msg) = self.process_envelope(envelope)
568                                    {
569                                        if let Some((token, response)) =
570                                            crate::util::parse_approval_reply(&msg.content)
571                                        {
572                                            let mut map = self.pending_approvals.lock().await;
573                                            if let Some(sender) = map.remove(&token) {
574                                                let _ = sender.send(response);
575                                                current_data.clear();
576                                                continue;
577                                            }
578                                        }
579                                        if tx.send(msg).await.is_err() {
580                                            return Ok(());
581                                        }
582                                    }
583                                }
584                                Err(e) => {
585                                    ::zeroclaw_log::record!(
586                                        DEBUG,
587                                        ::zeroclaw_log::Event::new(
588                                            module_path!(),
589                                            ::zeroclaw_log::Action::Note
590                                        )
591                                        .with_attrs(
592                                            ::serde_json::json!({"error": format!("{}", e)})
593                                        ),
594                                        "SSE parse skip"
595                                    );
596                                }
597                            }
598                            current_data.clear();
599                        }
600                    } else if let Some(data) = line.strip_prefix("data:") {
601                        if !current_data.is_empty() {
602                            current_data.push('\n');
603                        }
604                        current_data.push_str(data.trim_start());
605                    }
606                    // Ignore "event:", "id:", "retry:" lines
607                }
608            }
609
610            if !current_data.is_empty() {
611                match serde_json::from_str::<SseEnvelope>(&current_data) {
612                    Ok(sse) => {
613                        if let Some(ref envelope) = sse.envelope
614                            && let Some(msg) = self.process_envelope(envelope)
615                        {
616                            if let Some((token, response)) =
617                                crate::util::parse_approval_reply(&msg.content)
618                            {
619                                let mut map = self.pending_approvals.lock().await;
620                                if let Some(sender) = map.remove(&token) {
621                                    let _ = sender.send(response);
622                                    continue;
623                                }
624                            }
625                            let _ = tx.send(msg).await;
626                        }
627                    }
628                    Err(e) => {
629                        ::zeroclaw_log::record!(
630                            DEBUG,
631                            ::zeroclaw_log::Event::new(
632                                module_path!(),
633                                ::zeroclaw_log::Action::Note
634                            )
635                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
636                            "SSE trailing parse skip"
637                        );
638                    }
639                }
640            }
641
642            ::zeroclaw_log::record!(
643                DEBUG,
644                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
645                "SSE stream ended, reconnecting..."
646            );
647            tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
648        }
649    }
650
651    async fn health_check(&self) -> bool {
652        let url = format!("{}/api/v1/check", self.http_url);
653        let Ok(resp) = self
654            .http_client()
655            .get(&url)
656            .timeout(Duration::from_secs(10))
657            .send()
658            .await
659        else {
660            return false;
661        };
662        resp.status().is_success()
663    }
664
665    async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
666        let params = match Self::parse_recipient_target(recipient) {
667            RecipientTarget::Direct(number) => serde_json::json!({
668                "recipient": [number],
669                "account": &self.account,
670            }),
671            RecipientTarget::Group(group_id) => serde_json::json!({
672                "groupId": group_id,
673                "account": &self.account,
674            }),
675        };
676        self.rpc_request("sendTyping", params).await?;
677        Ok(())
678    }
679
680    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
681        // signal-cli doesn't have a stop-typing RPC; typing indicators
682        // auto-expire after ~15s on the client side.
683        Ok(())
684    }
685
686    async fn add_reaction(
687        &self,
688        channel_id: &str,
689        message_id: &str,
690        emoji: &str,
691    ) -> anyhow::Result<()> {
692        let params = self.build_reaction_params(channel_id, message_id, emoji, false)?;
693        self.rpc_request("sendReaction", params).await?;
694        Ok(())
695    }
696
697    async fn remove_reaction(
698        &self,
699        channel_id: &str,
700        message_id: &str,
701        emoji: &str,
702    ) -> anyhow::Result<()> {
703        let params = self.build_reaction_params(channel_id, message_id, emoji, true)?;
704        self.rpc_request("sendReaction", params).await?;
705        Ok(())
706    }
707
708    async fn request_approval(
709        &self,
710        recipient: &str,
711        request: &ChannelApprovalRequest,
712    ) -> anyhow::Result<Option<ChannelApprovalResponse>> {
713        let token = crate::util::new_approval_token();
714        let text = format!(
715            "APPROVAL REQUIRED [{}]\nTool: {}\nArgs: {}\n\nReply: \"{} yes\", \"{} no\", or \"{} always\"",
716            token, request.tool_name, request.arguments_summary, token, token, token,
717        );
718
719        let (tx, rx) = oneshot::channel();
720        self.pending_approvals
721            .lock()
722            .await
723            .insert(token.clone(), tx);
724
725        if let Err(err) = self.send(&SendMessage::new(text, recipient)).await {
726            self.pending_approvals.lock().await.remove(&token);
727            return Err(err);
728        }
729
730        let response =
731            match tokio::time::timeout(Duration::from_secs(self.approval_timeout_secs), rx).await {
732                Ok(Ok(resp)) => resp,
733                _ => {
734                    self.pending_approvals.lock().await.remove(&token);
735                    ChannelApprovalResponse::Deny
736                }
737            };
738        Ok(Some(response))
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    fn make_envelope(source_number: Option<&str>, message: Option<&str>) -> Envelope {
747        Envelope {
748            source: source_number.map(String::from),
749            source_number: source_number.map(String::from),
750            data_message: message.map(|m| DataMessage {
751                message: Some(m.to_string()),
752                timestamp: Some(1_700_000_000_000),
753                group_info: None,
754                attachments: None,
755            }),
756            story_message: None,
757            timestamp: Some(1_700_000_000_000),
758        }
759    }
760
761    #[test]
762    fn creates_with_correct_fields() {
763        let dm_only = false;
764        let ignore_attachments = false;
765        let ignore_stories = false;
766        let ch = SignalChannel::new(
767            "http://127.0.0.1:8686".to_string(),
768            "+1234567890".to_string(),
769            Vec::new(),
770            dm_only,
771            "signal_test_alias",
772            Arc::new(|| vec!["+1111111111".into()]),
773            ignore_attachments,
774            ignore_stories,
775        );
776        assert_eq!(ch.http_url, "http://127.0.0.1:8686");
777        assert_eq!(ch.account, "+1234567890");
778        assert!(ch.group_ids.is_empty());
779        assert!(!ch.dm_only);
780        assert!(ch.is_sender_allowed("+1111111111"));
781        assert!(!ch.ignore_attachments);
782        assert!(!ch.ignore_stories);
783    }
784
785    #[test]
786    fn strips_trailing_slash() {
787        let dm_only = false;
788        let ignore_attachments = false;
789        let ignore_stories = false;
790        let ch = SignalChannel::new(
791            "http://127.0.0.1:8686/".to_string(),
792            "+1234567890".to_string(),
793            Vec::new(),
794            dm_only,
795            "signal_test_alias",
796            Arc::new(Vec::new),
797            ignore_attachments,
798            ignore_stories,
799        );
800        assert_eq!(ch.http_url, "http://127.0.0.1:8686");
801    }
802
803    #[test]
804    fn wildcard_allows_anyone() {
805        let dm_only = true;
806        let ignore_attachments = true;
807        let ignore_stories = true;
808        let ch = SignalChannel::new(
809            "http://127.0.0.1:8686".to_string(),
810            "+1234567890".to_string(),
811            Vec::new(),
812            dm_only,
813            "signal_test_alias",
814            Arc::new(|| vec!["*".into()]),
815            ignore_attachments,
816            ignore_stories,
817        );
818        assert!(ch.is_sender_allowed("+9999999999"));
819    }
820
821    #[test]
822    fn specific_sender_allowed() {
823        let dm_only = false;
824        let ignore_attachments = false;
825        let ignore_stories = false;
826        let ch = SignalChannel::new(
827            "http://127.0.0.1:8686".to_string(),
828            "+1234567890".to_string(),
829            Vec::new(),
830            dm_only,
831            "signal_test_alias",
832            Arc::new(|| vec!["+1111111111".into()]),
833            ignore_attachments,
834            ignore_stories,
835        );
836        assert!(ch.is_sender_allowed("+1111111111"));
837    }
838
839    #[test]
840    fn unknown_sender_denied() {
841        let dm_only = false;
842        let ignore_attachments = false;
843        let ignore_stories = false;
844        let ch = SignalChannel::new(
845            "http://127.0.0.1:8686".to_string(),
846            "+1234567890".to_string(),
847            Vec::new(),
848            dm_only,
849            "signal_test_alias",
850            Arc::new(|| vec!["+1111111111".into()]),
851            ignore_attachments,
852            ignore_stories,
853        );
854        assert!(!ch.is_sender_allowed("+9999999999"));
855    }
856
857    #[test]
858    fn empty_allowlist_denies_all() {
859        let dm_only = false;
860        let ignore_attachments = false;
861        let ignore_stories = false;
862        let ch = SignalChannel::new(
863            "http://127.0.0.1:8686".to_string(),
864            "+1234567890".to_string(),
865            Vec::new(),
866            dm_only,
867            "signal_test_alias",
868            Arc::new(Vec::new),
869            ignore_attachments,
870            ignore_stories,
871        );
872        assert!(!ch.is_sender_allowed("+1111111111"));
873    }
874
875    #[test]
876    fn name_returns_signal() {
877        let dm_only = false;
878        let ignore_attachments = false;
879        let ignore_stories = false;
880        let ch = SignalChannel::new(
881            "http://127.0.0.1:8686".to_string(),
882            "+1234567890".to_string(),
883            Vec::new(),
884            dm_only,
885            "signal_test_alias",
886            Arc::new(|| vec!["+1111111111".into()]),
887            ignore_attachments,
888            ignore_stories,
889        );
890        assert_eq!(ch.name(), "signal");
891    }
892
893    #[test]
894    fn matches_group_no_group_id_accepts_all() {
895        let dm_only = false;
896        let ignore_attachments = false;
897        let ignore_stories = false;
898        let ch = SignalChannel::new(
899            "http://127.0.0.1:8686".to_string(),
900            "+1234567890".to_string(),
901            Vec::new(),
902            dm_only,
903            "signal_test_alias",
904            Arc::new(|| vec!["+1111111111".into()]),
905            ignore_attachments,
906            ignore_stories,
907        );
908        let dm = DataMessage {
909            message: Some("hi".to_string()),
910            timestamp: Some(1000),
911            group_info: None,
912            attachments: None,
913        };
914        assert!(ch.matches_group(&dm));
915
916        let group = DataMessage {
917            message: Some("hi".to_string()),
918            timestamp: Some(1000),
919            group_info: Some(GroupInfo {
920                group_id: Some("group123".to_string()),
921            }),
922            attachments: None,
923        };
924        assert!(ch.matches_group(&group));
925    }
926
927    #[test]
928    fn matches_group_filters_group() {
929        let dm_only = false;
930        let ignore_attachments = true;
931        let ignore_stories = true;
932        let ch = SignalChannel::new(
933            "http://127.0.0.1:8686".to_string(),
934            "+1234567890".to_string(),
935            vec!["group123".to_string()],
936            dm_only,
937            "signal_test_alias",
938            Arc::new(|| vec!["*".into()]),
939            ignore_attachments,
940            ignore_stories,
941        );
942        let matching = DataMessage {
943            message: Some("hi".to_string()),
944            timestamp: Some(1000),
945            group_info: Some(GroupInfo {
946                group_id: Some("group123".to_string()),
947            }),
948            attachments: None,
949        };
950        assert!(ch.matches_group(&matching));
951
952        let non_matching = DataMessage {
953            message: Some("hi".to_string()),
954            timestamp: Some(1000),
955            group_info: Some(GroupInfo {
956                group_id: Some("other_group".to_string()),
957            }),
958            attachments: None,
959        };
960        assert!(!ch.matches_group(&non_matching));
961    }
962
963    #[test]
964    fn matches_group_dm_keyword() {
965        let dm_only = true;
966        let ignore_attachments = true;
967        let ignore_stories = true;
968        let ch = SignalChannel::new(
969            "http://127.0.0.1:8686".to_string(),
970            "+1234567890".to_string(),
971            Vec::new(),
972            dm_only,
973            "signal_test_alias",
974            Arc::new(|| vec!["*".into()]),
975            ignore_attachments,
976            ignore_stories,
977        );
978        let dm = DataMessage {
979            message: Some("hi".to_string()),
980            timestamp: Some(1000),
981            group_info: None,
982            attachments: None,
983        };
984        assert!(ch.matches_group(&dm));
985
986        let group = DataMessage {
987            message: Some("hi".to_string()),
988            timestamp: Some(1000),
989            group_info: Some(GroupInfo {
990                group_id: Some("group123".to_string()),
991            }),
992            attachments: None,
993        };
994        assert!(!ch.matches_group(&group));
995    }
996
997    #[test]
998    fn reply_target_dm() {
999        let dm_only = false;
1000        let ignore_attachments = false;
1001        let ignore_stories = false;
1002        let ch = SignalChannel::new(
1003            "http://127.0.0.1:8686".to_string(),
1004            "+1234567890".to_string(),
1005            Vec::new(),
1006            dm_only,
1007            "signal_test_alias",
1008            Arc::new(|| vec!["+1111111111".into()]),
1009            ignore_attachments,
1010            ignore_stories,
1011        );
1012        let dm = DataMessage {
1013            message: Some("hi".to_string()),
1014            timestamp: Some(1000),
1015            group_info: None,
1016            attachments: None,
1017        };
1018        assert_eq!(ch.reply_target(&dm, "+1111111111"), "+1111111111");
1019    }
1020
1021    #[test]
1022    fn reply_target_group() {
1023        let dm_only = false;
1024        let ignore_attachments = false;
1025        let ignore_stories = false;
1026        let ch = SignalChannel::new(
1027            "http://127.0.0.1:8686".to_string(),
1028            "+1234567890".to_string(),
1029            Vec::new(),
1030            dm_only,
1031            "signal_test_alias",
1032            Arc::new(|| vec!["+1111111111".into()]),
1033            ignore_attachments,
1034            ignore_stories,
1035        );
1036        let group = DataMessage {
1037            message: Some("hi".to_string()),
1038            timestamp: Some(1000),
1039            group_info: Some(GroupInfo {
1040                group_id: Some("group123".to_string()),
1041            }),
1042            attachments: None,
1043        };
1044        assert_eq!(ch.reply_target(&group, "+1111111111"), "group:group123");
1045    }
1046
1047    #[test]
1048    fn parse_recipient_target_e164_is_direct() {
1049        assert_eq!(
1050            SignalChannel::parse_recipient_target("+1234567890"),
1051            RecipientTarget::Direct("+1234567890".to_string())
1052        );
1053    }
1054
1055    #[test]
1056    fn parse_recipient_target_prefixed_group_is_group() {
1057        assert_eq!(
1058            SignalChannel::parse_recipient_target("group:abc123"),
1059            RecipientTarget::Group("abc123".to_string())
1060        );
1061    }
1062
1063    #[test]
1064    fn parse_recipient_target_uuid_is_direct() {
1065        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1066        assert_eq!(
1067            SignalChannel::parse_recipient_target(uuid),
1068            RecipientTarget::Direct(uuid.to_string())
1069        );
1070    }
1071
1072    #[test]
1073    fn parse_recipient_target_non_e164_plus_is_group() {
1074        assert_eq!(
1075            SignalChannel::parse_recipient_target("+abc123"),
1076            RecipientTarget::Group("+abc123".to_string())
1077        );
1078    }
1079
1080    #[test]
1081    fn is_uuid_valid() {
1082        assert!(SignalChannel::is_uuid(
1083            "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1084        ));
1085        assert!(SignalChannel::is_uuid(
1086            "00000000-0000-0000-0000-000000000000"
1087        ));
1088    }
1089
1090    #[test]
1091    fn is_uuid_invalid() {
1092        assert!(!SignalChannel::is_uuid("+1234567890"));
1093        assert!(!SignalChannel::is_uuid("not-a-uuid"));
1094        assert!(!SignalChannel::is_uuid("group:abc123"));
1095        assert!(!SignalChannel::is_uuid(""));
1096    }
1097
1098    #[test]
1099    fn sender_prefers_source_number() {
1100        let env = Envelope {
1101            source: Some("uuid-123".to_string()),
1102            source_number: Some("+1111111111".to_string()),
1103            data_message: None,
1104            story_message: None,
1105            timestamp: Some(1000),
1106        };
1107        assert_eq!(SignalChannel::sender(&env), Some("+1111111111".to_string()));
1108    }
1109
1110    #[test]
1111    fn sender_falls_back_to_source() {
1112        let env = Envelope {
1113            source: Some("uuid-123".to_string()),
1114            source_number: None,
1115            data_message: None,
1116            story_message: None,
1117            timestamp: Some(1000),
1118        };
1119        assert_eq!(SignalChannel::sender(&env), Some("uuid-123".to_string()));
1120    }
1121
1122    #[test]
1123    fn process_envelope_uuid_sender_dm() {
1124        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1125        let dm_only = false;
1126        let ignore_attachments = false;
1127        let ignore_stories = false;
1128        let ch = SignalChannel::new(
1129            "http://127.0.0.1:8686".to_string(),
1130            "+1234567890".to_string(),
1131            Vec::new(),
1132            dm_only,
1133            "signal_test_alias",
1134            Arc::new(|| vec!["*".into()]),
1135            ignore_attachments,
1136            ignore_stories,
1137        );
1138        let env = Envelope {
1139            source: Some(uuid.to_string()),
1140            source_number: None,
1141            data_message: Some(DataMessage {
1142                message: Some("Hello from privacy user".to_string()),
1143                timestamp: Some(1_700_000_000_000),
1144                group_info: None,
1145                attachments: None,
1146            }),
1147            story_message: None,
1148            timestamp: Some(1_700_000_000_000),
1149        };
1150        let msg = ch.process_envelope(&env).unwrap();
1151        assert_eq!(msg.sender, uuid);
1152        assert_eq!(msg.reply_target, uuid);
1153        assert_eq!(msg.content, "Hello from privacy user");
1154        assert!(
1155            msg.id.starts_with("sig_1700000000000_"),
1156            "id should embed timestamp but stay opaque: {}",
1157            msg.id
1158        );
1159        // Privacy regression: the routing identity must not appear in the
1160        // generic message id, which flows into logs, memory keys, and the
1161        // LLM-facing tool context.
1162        assert!(
1163            !msg.id.contains(uuid),
1164            "UUID sender must not leak into msg.id: {}",
1165            msg.id
1166        );
1167        assert_eq!(msg.timestamp, 1_700_000_000);
1168        assert_eq!(msg.channel_alias.as_deref(), Some("signal_test_alias"));
1169
1170        // Verify reply routing: UUID sender in DM should route as Direct
1171        let target = SignalChannel::parse_recipient_target(&msg.reply_target);
1172        assert_eq!(target, RecipientTarget::Direct(uuid.to_string()));
1173    }
1174
1175    #[test]
1176    fn process_envelope_uuid_sender_in_group() {
1177        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1178        let dm_only = false;
1179        let ignore_attachments = false;
1180        let ignore_stories = false;
1181        let ch = SignalChannel::new(
1182            "http://127.0.0.1:8686".to_string(),
1183            "+1234567890".to_string(),
1184            vec!["testgroup".to_string()],
1185            dm_only,
1186            "signal_test_alias",
1187            Arc::new(|| vec!["*".into()]),
1188            ignore_attachments,
1189            ignore_stories,
1190        );
1191        let env = Envelope {
1192            source: Some(uuid.to_string()),
1193            source_number: None,
1194            data_message: Some(DataMessage {
1195                message: Some("Group msg from privacy user".to_string()),
1196                timestamp: Some(1_700_000_000_000),
1197                group_info: Some(GroupInfo {
1198                    group_id: Some("testgroup".to_string()),
1199                }),
1200                attachments: None,
1201            }),
1202            story_message: None,
1203            timestamp: Some(1_700_000_000_000),
1204        };
1205        let msg = ch.process_envelope(&env).unwrap();
1206        assert_eq!(msg.sender, uuid);
1207        assert_eq!(msg.reply_target, "group:testgroup");
1208
1209        // Verify reply routing: group message should still route as Group
1210        let target = SignalChannel::parse_recipient_target(&msg.reply_target);
1211        assert_eq!(target, RecipientTarget::Group("testgroup".to_string()));
1212    }
1213
1214    #[test]
1215    fn sender_none_when_both_missing() {
1216        let env = Envelope {
1217            source: None,
1218            source_number: None,
1219            data_message: None,
1220            story_message: None,
1221            timestamp: None,
1222        };
1223        assert_eq!(SignalChannel::sender(&env), None);
1224    }
1225
1226    #[test]
1227    fn process_envelope_valid_dm() {
1228        let dm_only = false;
1229        let ignore_attachments = false;
1230        let ignore_stories = false;
1231        let ch = SignalChannel::new(
1232            "http://127.0.0.1:8686".to_string(),
1233            "+1234567890".to_string(),
1234            Vec::new(),
1235            dm_only,
1236            "signal_test_alias",
1237            Arc::new(|| vec!["+1111111111".into()]),
1238            ignore_attachments,
1239            ignore_stories,
1240        );
1241        let env = make_envelope(Some("+1111111111"), Some("Hello!"));
1242        let msg = ch.process_envelope(&env).unwrap();
1243        assert_eq!(msg.content, "Hello!");
1244        assert_eq!(msg.sender, "+1111111111");
1245        assert_eq!(msg.channel, "signal");
1246        assert!(
1247            msg.id.starts_with("sig_1700000000000_"),
1248            "id should embed timestamp but stay opaque: {}",
1249            msg.id
1250        );
1251        // Privacy regression: the E.164 phone number must not appear in
1252        // the generic message id, which flows into logs, memory keys, and
1253        // the LLM-facing tool context.
1254        assert!(
1255            !msg.id.contains("+1111111111"),
1256            "E.164 sender must not leak into msg.id: {}",
1257            msg.id
1258        );
1259        assert_eq!(msg.timestamp, 1_700_000_000);
1260        assert_eq!(msg.channel_alias.as_deref(), Some("signal_test_alias"));
1261    }
1262
1263    #[test]
1264    fn process_envelope_denied_sender() {
1265        let dm_only = false;
1266        let ignore_attachments = false;
1267        let ignore_stories = false;
1268        let ch = SignalChannel::new(
1269            "http://127.0.0.1:8686".to_string(),
1270            "+1234567890".to_string(),
1271            Vec::new(),
1272            dm_only,
1273            "signal_test_alias",
1274            Arc::new(|| vec!["+1111111111".into()]),
1275            ignore_attachments,
1276            ignore_stories,
1277        );
1278        let env = make_envelope(Some("+9999999999"), Some("Hello!"));
1279        assert!(ch.process_envelope(&env).is_none());
1280    }
1281
1282    #[test]
1283    fn process_envelope_empty_message() {
1284        let dm_only = false;
1285        let ignore_attachments = false;
1286        let ignore_stories = false;
1287        let ch = SignalChannel::new(
1288            "http://127.0.0.1:8686".to_string(),
1289            "+1234567890".to_string(),
1290            Vec::new(),
1291            dm_only,
1292            "signal_test_alias",
1293            Arc::new(|| vec!["+1111111111".into()]),
1294            ignore_attachments,
1295            ignore_stories,
1296        );
1297        let env = make_envelope(Some("+1111111111"), Some(""));
1298        assert!(ch.process_envelope(&env).is_none());
1299    }
1300
1301    #[test]
1302    fn process_envelope_no_data_message() {
1303        let dm_only = false;
1304        let ignore_attachments = false;
1305        let ignore_stories = false;
1306        let ch = SignalChannel::new(
1307            "http://127.0.0.1:8686".to_string(),
1308            "+1234567890".to_string(),
1309            Vec::new(),
1310            dm_only,
1311            "signal_test_alias",
1312            Arc::new(|| vec!["+1111111111".into()]),
1313            ignore_attachments,
1314            ignore_stories,
1315        );
1316        let env = make_envelope(Some("+1111111111"), None);
1317        assert!(ch.process_envelope(&env).is_none());
1318    }
1319
1320    #[test]
1321    fn process_envelope_skips_stories() {
1322        let dm_only = true;
1323        let ignore_attachments = true;
1324        let ignore_stories = true;
1325        let ch = SignalChannel::new(
1326            "http://127.0.0.1:8686".to_string(),
1327            "+1234567890".to_string(),
1328            Vec::new(),
1329            dm_only,
1330            "signal_test_alias",
1331            Arc::new(|| vec!["*".into()]),
1332            ignore_attachments,
1333            ignore_stories,
1334        );
1335        let mut env = make_envelope(Some("+1111111111"), Some("story text"));
1336        env.story_message = Some(serde_json::json!({}));
1337        assert!(ch.process_envelope(&env).is_none());
1338    }
1339
1340    #[test]
1341    fn process_envelope_skips_attachment_only() {
1342        let dm_only = true;
1343        let ignore_attachments = true;
1344        let ignore_stories = true;
1345        let ch = SignalChannel::new(
1346            "http://127.0.0.1:8686".to_string(),
1347            "+1234567890".to_string(),
1348            Vec::new(),
1349            dm_only,
1350            "signal_test_alias",
1351            Arc::new(|| vec!["*".into()]),
1352            ignore_attachments,
1353            ignore_stories,
1354        );
1355        let env = Envelope {
1356            source: Some("+1111111111".to_string()),
1357            source_number: Some("+1111111111".to_string()),
1358            data_message: Some(DataMessage {
1359                message: None,
1360                timestamp: Some(1_700_000_000_000),
1361                group_info: None,
1362                attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]),
1363            }),
1364            story_message: None,
1365            timestamp: Some(1_700_000_000_000),
1366        };
1367        assert!(ch.process_envelope(&env).is_none());
1368    }
1369
1370    #[test]
1371    fn process_envelope_group_happy_path() {
1372        let dm_only = false;
1373        let ignore_attachments = false;
1374        let ignore_stories = false;
1375        let ch = SignalChannel::new(
1376            "http://127.0.0.1:8686".to_string(),
1377            "+1234567890".to_string(),
1378            vec!["group_xyz".to_string()],
1379            dm_only,
1380            "signal_test_alias",
1381            Arc::new(|| vec!["+1111111111".into()]),
1382            ignore_attachments,
1383            ignore_stories,
1384        );
1385        let env = Envelope {
1386            source: Some("+1111111111".to_string()),
1387            source_number: Some("+1111111111".to_string()),
1388            data_message: Some(DataMessage {
1389                message: Some("group hello".to_string()),
1390                timestamp: Some(1_700_000_000_000),
1391                group_info: Some(GroupInfo {
1392                    group_id: Some("group_xyz".to_string()),
1393                }),
1394                attachments: None,
1395            }),
1396            story_message: None,
1397            timestamp: Some(1_700_000_000_000),
1398        };
1399        let msg = ch.process_envelope(&env).unwrap();
1400        assert_eq!(msg.sender, "+1111111111");
1401        assert_eq!(msg.reply_target, "group:group_xyz");
1402        assert_eq!(msg.content, "group hello");
1403        assert_eq!(msg.channel, "signal");
1404        assert!(
1405            msg.id.starts_with("sig_1700000000000_"),
1406            "id should embed timestamp but stay opaque: {}",
1407            msg.id
1408        );
1409        // Privacy regression: the in-group sender must not appear in the
1410        // generic message id, even though the group id itself is in
1411        // `reply_target` and not sensitive.
1412        assert!(
1413            !msg.id.contains("+1111111111"),
1414            "E.164 sender must not leak into group msg.id: {}",
1415            msg.id
1416        );
1417        assert_eq!(msg.timestamp, 1_700_000_000);
1418        assert_eq!(msg.channel_alias.as_deref(), Some("signal_test_alias"));
1419    }
1420
1421    #[test]
1422    fn process_envelope_populates_recent_targets() {
1423        // The opaque `msg.id` is unusable for `sendReaction` on its own —
1424        // signal-cli needs `(targetAuthor, targetTimestamp)`. Confirm the
1425        // channel-local lookup is seeded so a later reaction can recover
1426        // those values without the id leaking the sender.
1427        let ch = SignalChannel::new(
1428            "http://127.0.0.1:8686".to_string(),
1429            "+1234567890".to_string(),
1430            vec!["group_xyz".to_string()],
1431            false,
1432            "signal_test_alias",
1433            Arc::new(|| vec!["+1111111111".into()]),
1434            false,
1435            false,
1436        );
1437        let env = Envelope {
1438            source: Some("+1111111111".to_string()),
1439            source_number: Some("+1111111111".to_string()),
1440            data_message: Some(DataMessage {
1441                message: Some("group hello".to_string()),
1442                timestamp: Some(1_700_000_000_000),
1443                group_info: Some(GroupInfo {
1444                    group_id: Some("group_xyz".to_string()),
1445                }),
1446                attachments: None,
1447            }),
1448            story_message: None,
1449            timestamp: Some(1_700_000_000_000),
1450        };
1451        let msg = ch.process_envelope(&env).unwrap();
1452        let target = ch
1453            .recent_targets
1454            .lock()
1455            .peek(&msg.id)
1456            .cloned()
1457            .expect("recent_targets should contain the just-emitted id");
1458        assert_eq!(target.author, "+1111111111");
1459        assert_eq!(target.timestamp_ms, 1_700_000_000_000);
1460    }
1461
1462    #[test]
1463    fn sse_envelope_deserializes() {
1464        let json = r#"{
1465            "envelope": {
1466                "source": "+1111111111",
1467                "sourceNumber": "+1111111111",
1468                "timestamp": 1700000000000,
1469                "dataMessage": {
1470                    "message": "Hello Signal!",
1471                    "timestamp": 1700000000000
1472                }
1473            }
1474        }"#;
1475        let sse: SseEnvelope = serde_json::from_str(json).unwrap();
1476        let env = sse.envelope.unwrap();
1477        assert_eq!(env.source_number.as_deref(), Some("+1111111111"));
1478        let dm = env.data_message.unwrap();
1479        assert_eq!(dm.message.as_deref(), Some("Hello Signal!"));
1480    }
1481
1482    #[test]
1483    fn sse_envelope_deserializes_group() {
1484        let json = r#"{
1485            "envelope": {
1486                "sourceNumber": "+2222222222",
1487                "dataMessage": {
1488                    "message": "Group msg",
1489                    "groupInfo": {
1490                        "groupId": "abc123"
1491                    }
1492                }
1493            }
1494        }"#;
1495        let sse: SseEnvelope = serde_json::from_str(json).unwrap();
1496        let env = sse.envelope.unwrap();
1497        let dm = env.data_message.unwrap();
1498        assert_eq!(
1499            dm.group_info.as_ref().unwrap().group_id.as_deref(),
1500            Some("abc123")
1501        );
1502    }
1503
1504    #[test]
1505    fn envelope_defaults() {
1506        let json = r#"{}"#;
1507        let env: Envelope = serde_json::from_str(json).unwrap();
1508        assert!(env.source.is_none());
1509        assert!(env.source_number.is_none());
1510        assert!(env.data_message.is_none());
1511        assert!(env.story_message.is_none());
1512        assert!(env.timestamp.is_none());
1513    }
1514
1515    #[test]
1516    fn pending_approvals_map_is_initially_empty() {
1517        let dm_only = false;
1518        let ignore_attachments = false;
1519        let ignore_stories = false;
1520        let ch = SignalChannel::new(
1521            "http://127.0.0.1:8686".to_string(),
1522            "+1234567890".to_string(),
1523            Vec::new(),
1524            dm_only,
1525            "signal_test_alias",
1526            Arc::new(|| vec!["+1111111111".into()]),
1527            ignore_attachments,
1528            ignore_stories,
1529        );
1530        let map = ch.pending_approvals.try_lock().unwrap();
1531        assert!(map.is_empty());
1532    }
1533
1534    #[test]
1535    fn approval_timeout_defaults_to_300_and_is_overridable() {
1536        let dm_only = false;
1537        let ignore_attachments = false;
1538        let ignore_stories = false;
1539        let ch = SignalChannel::new(
1540            "http://127.0.0.1:8686".to_string(),
1541            "+1234567890".to_string(),
1542            Vec::new(),
1543            dm_only,
1544            "signal_test_alias",
1545            Arc::new(|| vec!["+1111111111".into()]),
1546            ignore_attachments,
1547            ignore_stories,
1548        );
1549        assert_eq!(ch.approval_timeout_secs, 300);
1550        let ch = ch.with_approval_timeout_secs(60);
1551        assert_eq!(ch.approval_timeout_secs, 60);
1552    }
1553
1554    #[tokio::test]
1555    async fn pending_approval_oneshot_delivers_response() {
1556        let dm_only = false;
1557        let ignore_attachments = false;
1558        let ignore_stories = false;
1559        let ch = SignalChannel::new(
1560            "http://127.0.0.1:8686".to_string(),
1561            "+1234567890".to_string(),
1562            Vec::new(),
1563            dm_only,
1564            "signal_test_alias",
1565            Arc::new(|| vec!["+1111111111".into()]),
1566            ignore_attachments,
1567            ignore_stories,
1568        );
1569        let (tx, rx) = tokio::sync::oneshot::channel();
1570        ch.pending_approvals
1571            .lock()
1572            .await
1573            .insert("abc123".to_string(), tx);
1574        // simulate listen() routing
1575        let sender = ch.pending_approvals.lock().await.remove("abc123").unwrap();
1576        sender.send(ChannelApprovalResponse::Approve).unwrap();
1577        assert_eq!(rx.await.unwrap(), ChannelApprovalResponse::Approve);
1578    }
1579
1580    fn make_reaction_channel() -> SignalChannel {
1581        SignalChannel::new(
1582            "http://127.0.0.1:8686".to_string(),
1583            "+1234567890".to_string(),
1584            Vec::new(),
1585            false,
1586            "signal_test_alias",
1587            Arc::new(|| vec!["*".into()]),
1588            false,
1589            false,
1590        )
1591    }
1592
1593    fn seed_reaction_target(ch: &SignalChannel, id: &str, author: &str, ts_ms: u64) {
1594        ch.recent_targets.lock().put(
1595            id.to_string(),
1596            ReactionTarget {
1597                author: author.to_string(),
1598                timestamp_ms: ts_ms,
1599            },
1600        );
1601    }
1602
1603    #[test]
1604    fn build_reaction_params_dm_includes_recipient() {
1605        let ch = make_reaction_channel();
1606        seed_reaction_target(
1607            &ch,
1608            "sig_1700000000000_abcdef",
1609            "+2222222222",
1610            1_700_000_000_000,
1611        );
1612        let params = ch
1613            .build_reaction_params(
1614                "+1111111111",
1615                "sig_1700000000000_abcdef",
1616                "\u{1F44D}",
1617                false,
1618            )
1619            .unwrap();
1620        assert_eq!(
1621            params["recipient"],
1622            serde_json::json!(["+1111111111".to_string()])
1623        );
1624        assert!(params.get("groupId").is_none());
1625        assert_eq!(params["emoji"], "\u{1F44D}");
1626        assert_eq!(params["targetAuthor"], "+2222222222");
1627        assert_eq!(params["targetTimestamp"], 1_700_000_000_000_u64);
1628        assert_eq!(params["remove"], false);
1629        assert_eq!(params["account"], "+1234567890");
1630    }
1631
1632    #[test]
1633    fn build_reaction_params_group_includes_group_id_and_remove() {
1634        let ch = make_reaction_channel();
1635        seed_reaction_target(
1636            &ch,
1637            "sig_1700000000000_abcdef",
1638            "+2222222222",
1639            1_700_000_000_000,
1640        );
1641        let params = ch
1642            .build_reaction_params(
1643                "group:abc",
1644                "sig_1700000000000_abcdef",
1645                "\u{2764}\u{FE0F}",
1646                true,
1647            )
1648            .unwrap();
1649        assert_eq!(params["groupId"], "abc");
1650        assert!(params.get("recipient").is_none());
1651        assert_eq!(params["emoji"], "\u{2764}\u{FE0F}");
1652        assert_eq!(params["targetAuthor"], "+2222222222");
1653        assert_eq!(params["targetTimestamp"], 1_700_000_000_000_u64);
1654        assert_eq!(params["remove"], true);
1655        assert_eq!(params["account"], "+1234567890");
1656    }
1657
1658    #[test]
1659    fn build_reaction_params_round_trips_uuid_sender_via_lookup() {
1660        // The opaque id reveals nothing about the sender, so the
1661        // round-trip property — that `sendReaction` ultimately sends the
1662        // correct `targetAuthor` — has to come from `process_envelope`
1663        // seeding the lookup, not from id parsing.
1664        let ch = make_reaction_channel();
1665        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1666        let env = Envelope {
1667            source: Some(uuid.to_string()),
1668            source_number: None,
1669            data_message: Some(DataMessage {
1670                message: Some("hi".to_string()),
1671                timestamp: Some(1_700_000_000_000),
1672                group_info: None,
1673                attachments: None,
1674            }),
1675            story_message: None,
1676            timestamp: Some(1_700_000_000_000),
1677        };
1678        let msg = ch.process_envelope(&env).unwrap();
1679        let params = ch
1680            .build_reaction_params(&msg.reply_target, &msg.id, "\u{1F44D}", false)
1681            .unwrap();
1682        assert_eq!(params["targetAuthor"], uuid);
1683        assert_eq!(params["targetTimestamp"], 1_700_000_000_000_u64);
1684    }
1685
1686    #[test]
1687    fn build_reaction_params_rejects_unknown_id() {
1688        let ch = make_reaction_channel();
1689        let err = ch
1690            .build_reaction_params("+1111111111", "sig_unknown_id", "\u{1F44D}", false)
1691            .unwrap_err();
1692        assert!(
1693            err.to_string().contains("no recent inbound Signal message"),
1694            "unexpected error: {err}"
1695        );
1696    }
1697}