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
19const RECENT_TARGETS_CAPACITY: usize = 1024;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28enum RecipientTarget {
29 Direct(String),
30 Group(String),
31}
32
33#[derive(Debug, Clone)]
36struct ReactionTarget {
37 author: String,
38 timestamp_ms: u64,
39}
40
41#[derive(Clone)]
47pub struct SignalChannel {
48 http_url: String,
49 account: String,
50 group_ids: Vec<String>,
52 dm_only: bool,
54 alias: String,
57 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
60 ignore_attachments: bool,
61 ignore_stories: bool,
62 proxy_url: Option<String>,
64 pending_approvals: Arc<Mutex<HashMap<String, oneshot::Sender<ChannelApprovalResponse>>>>,
65 approval_timeout_secs: u64,
68 recent_targets: Arc<SyncMutex<LruCache<String, ReactionTarget>>>,
73}
74
75#[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 pub fn alias(&self) -> &str {
149 &self.alias
150 }
151
152 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 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 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 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 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 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 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 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 fn process_envelope(&self, envelope: &Envelope) -> Option<ChannelMessage> {
345 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 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 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, 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 if line.starts_with(':') {
558 continue;
559 }
560
561 if line.is_empty() {
562 if !current_data.is_empty() {
564 match serde_json::from_str::<SseEnvelope>(¤t_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 }
608 }
609
610 if !current_data.is_empty() {
611 match serde_json::from_str::<SseEnvelope>(¤t_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 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 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 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 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 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 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 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 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 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}