1use async_trait::async_trait;
2use std::sync::Arc;
3use uuid::Uuid;
4use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
5
6pub struct LinqChannel {
13 api_token: String,
14 from_phone: String,
15 alias: String,
18 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
21 client: reqwest::Client,
22}
23
24const LINQ_API_BASE: &str = "https://api.linqapp.com/api/partner/v3";
25
26impl LinqChannel {
27 pub fn new(
28 api_token: String,
29 from_phone: String,
30 alias: impl Into<String>,
31 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
32 ) -> Self {
33 Self {
34 api_token,
35 from_phone,
36 alias: alias.into(),
37 peer_resolver,
38 client: reqwest::Client::new(),
39 }
40 }
41
42 pub fn alias(&self) -> &str {
45 &self.alias
46 }
47
48 fn is_sender_allowed(&self, phone: &str) -> bool {
50 let peers = (self.peer_resolver)();
51 crate::allowlist::is_user_allowed(&peers, phone, crate::allowlist::Match::Sensitive)
52 }
53
54 pub fn phone_number(&self) -> &str {
56 &self.from_phone
57 }
58
59 fn media_part_to_image_marker(part: &serde_json::Value) -> Option<String> {
60 let source = part
61 .get("url")
62 .or_else(|| part.get("value"))
63 .and_then(|value| value.as_str())
64 .map(str::trim)
65 .filter(|value| !value.is_empty())?;
66
67 let mime_type = part
68 .get("mime_type")
69 .and_then(|value| value.as_str())
70 .map(str::trim)
71 .unwrap_or_default()
72 .to_ascii_lowercase();
73
74 if !mime_type.starts_with("image/") {
75 return None;
76 }
77
78 Some(format!("[IMAGE:{source}]"))
79 }
80
81 fn sender_is_from_me(data: &serde_json::Value) -> bool {
82 if let Some(v) = data.get("is_from_me").and_then(|value| value.as_bool()) {
84 return v;
85 }
86
87 let is_me = data
89 .get("sender_handle")
90 .and_then(|value| value.get("is_me"))
91 .and_then(|value| value.as_bool())
92 .unwrap_or(false);
93
94 let is_outbound = matches!(
95 data.get("direction").and_then(|value| value.as_str()),
96 Some("outbound")
97 );
98
99 is_me || is_outbound
100 }
101
102 fn sender_handle(data: &serde_json::Value) -> Option<&str> {
103 data.get("from")
104 .and_then(|value| value.as_str())
105 .or_else(|| {
106 data.get("sender_handle")
107 .and_then(|value| value.get("handle"))
108 .and_then(|value| value.as_str())
109 })
110 }
111
112 fn chat_id(data: &serde_json::Value) -> Option<&str> {
113 data.get("chat_id")
114 .and_then(|value| value.as_str())
115 .or_else(|| {
116 data.get("chat")
117 .and_then(|value| value.get("id"))
118 .and_then(|value| value.as_str())
119 })
120 }
121
122 fn message_parts(data: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
123 data.get("message")
124 .and_then(|value| value.get("parts"))
125 .and_then(|value| value.as_array())
126 .or_else(|| data.get("parts").and_then(|value| value.as_array()))
127 }
128
129 pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
171 let mut messages = Vec::new();
172
173 let event_type = payload
175 .get("event_type")
176 .and_then(|e| e.as_str())
177 .unwrap_or("");
178 if event_type != "message.received" {
179 ::zeroclaw_log::record!(
180 DEBUG,
181 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
182 .with_attrs(::serde_json::json!({"event_type": event_type})),
183 "skipping non-message event"
184 );
185 return messages;
186 }
187
188 let Some(data) = payload.get("data") else {
189 return messages;
190 };
191
192 if Self::sender_is_from_me(data) {
194 ::zeroclaw_log::record!(
195 DEBUG,
196 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
197 "skipping is_from_me message"
198 );
199 return messages;
200 }
201
202 let Some(from) = Self::sender_handle(data) else {
204 return messages;
205 };
206
207 let normalized_from = if from.starts_with('+') {
209 from.to_string()
210 } else {
211 format!("+{from}")
212 };
213
214 if !self.is_sender_allowed(&normalized_from) {
216 ::zeroclaw_log::record!(
217 WARN,
218 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
219 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
220 .with_attrs(::serde_json::json!({"normalized_from": normalized_from})),
221 "ignoring message from unauthorized sender: . Add to channels.linq.allowed_senders in config.toml, or run `zeroclaw onboard channels` to configure interactively."
222 );
223 return messages;
224 }
225
226 let chat_id = Self::chat_id(data).unwrap_or("").to_string();
228
229 let Some(parts) = Self::message_parts(data) else {
231 return messages;
232 };
233
234 let content_parts: Vec<String> = parts
235 .iter()
236 .filter_map(|part| {
237 let part_type = part.get("type").and_then(|t| t.as_str())?;
238 match part_type {
239 "text" => part
240 .get("value")
241 .and_then(|v| v.as_str())
242 .map(ToString::to_string),
243 "media" | "image" => {
244 if let Some(marker) = Self::media_part_to_image_marker(part) {
245 Some(marker)
246 } else {
247 ::zeroclaw_log::record!(
248 DEBUG,
249 ::zeroclaw_log::Event::new(
250 module_path!(),
251 ::zeroclaw_log::Action::Note
252 )
253 .with_attrs(::serde_json::json!({"part_type": part_type})),
254 "skipping unsupported part"
255 );
256 None
257 }
258 }
259 _ => {
260 ::zeroclaw_log::record!(
261 DEBUG,
262 ::zeroclaw_log::Event::new(
263 module_path!(),
264 ::zeroclaw_log::Action::Note
265 )
266 .with_attrs(::serde_json::json!({"part_type": part_type})),
267 "skipping part"
268 );
269 None
270 }
271 }
272 })
273 .collect();
274
275 if content_parts.is_empty() {
276 return messages;
277 }
278
279 let content = content_parts.join("\n").trim().to_string();
280
281 if content.is_empty() {
282 return messages;
283 }
284
285 let timestamp = payload
287 .get("created_at")
288 .and_then(|t| t.as_str())
289 .and_then(|t| {
290 chrono::DateTime::parse_from_rfc3339(t)
291 .ok()
292 .map(|dt| dt.timestamp().cast_unsigned())
293 })
294 .unwrap_or_else(|| {
295 std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .unwrap_or_default()
298 .as_secs()
299 });
300
301 let reply_target = if chat_id.is_empty() {
303 normalized_from.clone()
304 } else {
305 chat_id
306 };
307
308 messages.push(ChannelMessage {
309 id: Uuid::new_v4().to_string(),
310 reply_target,
311 sender: normalized_from,
312 content,
313 channel: "linq".to_string(),
314 channel_alias: Some(self.alias.clone()),
315 timestamp,
316 thread_ts: None,
317 interruption_scope_id: None,
318 attachments: vec![],
319 subject: None,
320 });
321
322 messages
323 }
324}
325
326impl ::zeroclaw_api::attribution::Attributable for LinqChannel {
327 fn role(&self) -> ::zeroclaw_api::attribution::Role {
328 ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Linq)
329 }
330 fn alias(&self) -> &str {
331 &self.alias
332 }
333}
334
335#[async_trait]
336impl Channel for LinqChannel {
337 fn name(&self) -> &str {
338 "linq"
339 }
340
341 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
342 let recipient = &message.recipient;
345
346 let body = serde_json::json!({
347 "message": {
348 "parts": [{
349 "type": "text",
350 "value": message.content
351 }]
352 }
353 });
354
355 let url = format!("{LINQ_API_BASE}/chats/{recipient}/messages");
357
358 let resp = self
359 .client
360 .post(&url)
361 .bearer_auth(&self.api_token)
362 .header("Content-Type", "application/json")
363 .json(&body)
364 .send()
365 .await?;
366
367 if resp.status().is_success() {
368 return Ok(());
369 }
370
371 if resp.status() == reqwest::StatusCode::NOT_FOUND {
373 let new_chat_body = serde_json::json!({
374 "from": self.from_phone,
375 "to": [recipient],
376 "message": {
377 "parts": [{
378 "type": "text",
379 "value": message.content
380 }]
381 }
382 });
383
384 let create_resp = self
385 .client
386 .post(format!("{LINQ_API_BASE}/chats"))
387 .bearer_auth(&self.api_token)
388 .header("Content-Type", "application/json")
389 .json(&new_chat_body)
390 .send()
391 .await?;
392
393 if !create_resp.status().is_success() {
394 let status = create_resp.status();
395 let error_body = create_resp.text().await.unwrap_or_default();
396 ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"status": status.to_string(), "error_body": error_body})), "create chat failed:");
397 anyhow::bail!("API error: {status}");
398 }
399
400 return Ok(());
401 }
402
403 let status = resp.status();
404 let error_body = resp.text().await.unwrap_or_default();
405 ::zeroclaw_log::record!(
406 ERROR,
407 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
408 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
409 .with_attrs(
410 ::serde_json::json!({"status": status.to_string(), "error_body": error_body})
411 ),
412 "send failed:"
413 );
414 anyhow::bail!("API error: {status}");
415 }
416
417 async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
418 ::zeroclaw_log::record!(
421 INFO,
422 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
423 "channel active (webhook mode). \
424 Configure Linq webhook to POST to your gateway's /linq endpoint."
425 );
426
427 loop {
429 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
430 }
431 }
432
433 async fn health_check(&self) -> bool {
434 let url = format!("{LINQ_API_BASE}/phonenumbers");
436
437 self.client
438 .get(&url)
439 .bearer_auth(&self.api_token)
440 .send()
441 .await
442 .map(|r| r.status().is_success())
443 .unwrap_or(false)
444 }
445
446 async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
447 let url = format!("{LINQ_API_BASE}/chats/{recipient}/typing");
448
449 let resp = self
450 .client
451 .post(&url)
452 .bearer_auth(&self.api_token)
453 .send()
454 .await?;
455
456 if !resp.status().is_success() {
457 ::zeroclaw_log::record!(
458 DEBUG,
459 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
460 &format!("start_typing failed: {}", resp.status())
461 );
462 }
463
464 Ok(())
465 }
466
467 async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
468 let url = format!("{LINQ_API_BASE}/chats/{recipient}/typing");
469
470 let resp = self
471 .client
472 .delete(&url)
473 .bearer_auth(&self.api_token)
474 .send()
475 .await?;
476
477 if !resp.status().is_success() {
478 ::zeroclaw_log::record!(
479 DEBUG,
480 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
481 &format!("stop_typing failed: {}", resp.status())
482 );
483 }
484
485 Ok(())
486 }
487}
488
489pub fn verify_linq_signature(secret: &str, body: &str, timestamp: &str, signature: &str) -> bool {
495 use hmac::{Hmac, Mac};
496 use sha2::Sha256;
497
498 if let Ok(ts) = timestamp.parse::<i64>() {
500 let now = chrono::Utc::now().timestamp();
501 if (now - ts).unsigned_abs() > 300 {
502 ::zeroclaw_log::record!(
503 WARN,
504 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
505 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
506 .with_attrs(::serde_json::json!({"ts": ts, "now": now})),
507 "rejecting stale webhook timestamp (, now=)"
508 );
509 return false;
510 }
511 } else {
512 ::zeroclaw_log::record!(
513 WARN,
514 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
515 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
516 .with_attrs(::serde_json::json!({"timestamp": timestamp})),
517 "invalid webhook timestamp"
518 );
519 return false;
520 }
521
522 let message = format!("{timestamp}.{body}");
524 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
525 return false;
526 };
527 mac.update(message.as_bytes());
528 let signature_hex = signature
529 .trim()
530 .strip_prefix("sha256=")
531 .unwrap_or(signature);
532 let Ok(provided) = hex::decode(signature_hex.trim()) else {
533 ::zeroclaw_log::record!(
534 WARN,
535 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
536 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
537 "invalid webhook signature format"
538 );
539 return false;
540 };
541
542 mac.verify_slice(&provided).is_ok()
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn linq_channel_name() {
552 let ch = LinqChannel::new(
553 "test-token".into(),
554 "+15551234567".into(),
555 "linq_test_alias",
556 Arc::new(|| vec!["+1234567890".into()]),
557 );
558 assert_eq!(ch.name(), "linq");
559 }
560
561 #[test]
562 fn linq_sender_allowed_exact() {
563 let ch = LinqChannel::new(
564 "test-token".into(),
565 "+15551234567".into(),
566 "linq_test_alias",
567 Arc::new(|| vec!["+1234567890".into()]),
568 );
569 assert!(ch.is_sender_allowed("+1234567890"));
570 assert!(!ch.is_sender_allowed("+9876543210"));
571 }
572
573 #[test]
574 fn linq_sender_allowed_wildcard() {
575 let ch = LinqChannel::new(
576 "tok".into(),
577 "+15551234567".into(),
578 "linq_test_alias",
579 Arc::new(|| vec!["*".into()]),
580 );
581 assert!(ch.is_sender_allowed("+1234567890"));
582 assert!(ch.is_sender_allowed("+9999999999"));
583 }
584
585 #[test]
586 fn linq_sender_allowed_empty() {
587 let ch = LinqChannel::new(
588 "tok".into(),
589 "+15551234567".into(),
590 "linq_test_alias",
591 Arc::new(Vec::new),
592 );
593 assert!(!ch.is_sender_allowed("+1234567890"));
594 }
595
596 #[test]
597 fn linq_parse_valid_text_message() {
598 let ch = LinqChannel::new(
599 "test-token".into(),
600 "+15551234567".into(),
601 "linq_test_alias",
602 Arc::new(|| vec!["+1234567890".into()]),
603 );
604 let payload = serde_json::json!({
605 "api_version": "v3",
606 "event_type": "message.received",
607 "event_id": "evt-123",
608 "created_at": "2025-01-15T12:00:00Z",
609 "trace_id": "trace-456",
610 "data": {
611 "chat_id": "chat-789",
612 "from": "+1234567890",
613 "recipient_phone": "+15551234567",
614 "is_from_me": false,
615 "service": "iMessage",
616 "message": {
617 "id": "msg-abc",
618 "parts": [{
619 "type": "text",
620 "value": "Hello ZeroClaw!"
621 }]
622 }
623 }
624 });
625
626 let msgs = ch.parse_webhook_payload(&payload);
627 assert_eq!(msgs.len(), 1);
628 assert_eq!(msgs[0].sender, "+1234567890");
629 assert_eq!(msgs[0].content, "Hello ZeroClaw!");
630 assert_eq!(msgs[0].channel, "linq");
631 assert_eq!(msgs[0].reply_target, "chat-789");
632 }
633
634 #[test]
635 fn linq_parse_latest_webhook_shape() {
636 let ch = LinqChannel::new(
637 "tok".into(),
638 "+15551234567".into(),
639 "linq_test_alias",
640 Arc::new(|| vec!["+1234567890".into()]),
641 );
642 let payload = serde_json::json!({
643 "api_version": "v3",
644 "webhook_version": "2026-02-03",
645 "event_type": "message.received",
646 "created_at": "2026-02-03T12:00:00Z",
647 "data": {
648 "chat": {
649 "id": "chat-2026"
650 },
651 "direction": "inbound",
652 "id": "msg-2026",
653 "parts": [{
654 "type": "text",
655 "value": "Hello from the latest payload"
656 }],
657 "sender_handle": {
658 "handle": "1234567890",
659 "is_me": false
660 }
661 }
662 });
663
664 let msgs = ch.parse_webhook_payload(&payload);
665 assert_eq!(msgs.len(), 1);
666 assert_eq!(msgs[0].sender, "+1234567890");
667 assert_eq!(msgs[0].content, "Hello from the latest payload");
668 assert_eq!(msgs[0].reply_target, "chat-2026");
669 }
670
671 #[test]
672 fn linq_parse_skip_is_from_me() {
673 let ch = LinqChannel::new(
674 "tok".into(),
675 "+15551234567".into(),
676 "linq_test_alias",
677 Arc::new(|| vec!["*".into()]),
678 );
679 let payload = serde_json::json!({
680 "event_type": "message.received",
681 "data": {
682 "chat_id": "chat-789",
683 "from": "+1234567890",
684 "is_from_me": true,
685 "message": {
686 "id": "msg-abc",
687 "parts": [{ "type": "text", "value": "My own message" }]
688 }
689 }
690 });
691
692 let msgs = ch.parse_webhook_payload(&payload);
693 assert!(msgs.is_empty(), "is_from_me messages should be skipped");
694 }
695
696 #[test]
697 fn linq_parse_skip_latest_outbound_message() {
698 let ch = LinqChannel::new(
699 "tok".into(),
700 "+15551234567".into(),
701 "linq_test_alias",
702 Arc::new(|| vec!["*".into()]),
703 );
704 let payload = serde_json::json!({
705 "event_type": "message.received",
706 "data": {
707 "chat": {
708 "id": "chat-789"
709 },
710 "direction": "outbound",
711 "parts": [{
712 "type": "text",
713 "value": "My own message"
714 }],
715 "sender_handle": {
716 "handle": "+1234567890",
717 "is_me": true
718 }
719 }
720 });
721
722 let msgs = ch.parse_webhook_payload(&payload);
723 assert!(
724 msgs.is_empty(),
725 "latest outbound messages from the bot should be skipped"
726 );
727 }
728
729 #[test]
730 fn linq_parse_skip_non_message_event() {
731 let ch = LinqChannel::new(
732 "test-token".into(),
733 "+15551234567".into(),
734 "linq_test_alias",
735 Arc::new(|| vec!["+1234567890".into()]),
736 );
737 let payload = serde_json::json!({
738 "event_type": "message.delivered",
739 "data": {
740 "chat_id": "chat-789",
741 "message_id": "msg-abc"
742 }
743 });
744
745 let msgs = ch.parse_webhook_payload(&payload);
746 assert!(msgs.is_empty(), "Non-message events should be skipped");
747 }
748
749 #[test]
750 fn linq_parse_unauthorized_sender() {
751 let ch = LinqChannel::new(
752 "test-token".into(),
753 "+15551234567".into(),
754 "linq_test_alias",
755 Arc::new(|| vec!["+1234567890".into()]),
756 );
757 let payload = serde_json::json!({
758 "event_type": "message.received",
759 "data": {
760 "chat_id": "chat-789",
761 "from": "+9999999999",
762 "is_from_me": false,
763 "message": {
764 "id": "msg-abc",
765 "parts": [{ "type": "text", "value": "Spam" }]
766 }
767 }
768 });
769
770 let msgs = ch.parse_webhook_payload(&payload);
771 assert!(msgs.is_empty(), "Unauthorized senders should be filtered");
772 }
773
774 #[test]
775 fn linq_parse_empty_payload() {
776 let ch = LinqChannel::new(
777 "test-token".into(),
778 "+15551234567".into(),
779 "linq_test_alias",
780 Arc::new(|| vec!["+1234567890".into()]),
781 );
782 let payload = serde_json::json!({});
783 let msgs = ch.parse_webhook_payload(&payload);
784 assert!(msgs.is_empty());
785 }
786
787 #[test]
788 fn linq_parse_media_only_translated_to_image_marker() {
789 let ch = LinqChannel::new(
790 "tok".into(),
791 "+15551234567".into(),
792 "linq_test_alias",
793 Arc::new(|| vec!["*".into()]),
794 );
795 let payload = serde_json::json!({
796 "event_type": "message.received",
797 "data": {
798 "chat_id": "chat-789",
799 "from": "+1234567890",
800 "is_from_me": false,
801 "message": {
802 "id": "msg-abc",
803 "parts": [{
804 "type": "media",
805 "url": "https://example.com/image.jpg",
806 "mime_type": "image/jpeg"
807 }]
808 }
809 }
810 });
811
812 let msgs = ch.parse_webhook_payload(&payload);
813 assert_eq!(msgs.len(), 1);
814 assert_eq!(msgs[0].content, "[IMAGE:https://example.com/image.jpg]");
815 }
816
817 #[test]
818 fn linq_parse_media_non_image_still_skipped() {
819 let ch = LinqChannel::new(
820 "tok".into(),
821 "+15551234567".into(),
822 "linq_test_alias",
823 Arc::new(|| vec!["*".into()]),
824 );
825 let payload = serde_json::json!({
826 "event_type": "message.received",
827 "data": {
828 "chat_id": "chat-789",
829 "from": "+1234567890",
830 "is_from_me": false,
831 "message": {
832 "id": "msg-abc",
833 "parts": [{
834 "type": "media",
835 "url": "https://example.com/sound.mp3",
836 "mime_type": "audio/mpeg"
837 }]
838 }
839 }
840 });
841
842 let msgs = ch.parse_webhook_payload(&payload);
843 assert!(msgs.is_empty(), "Non-image media should still be skipped");
844 }
845
846 #[test]
847 fn linq_parse_multiple_text_parts() {
848 let ch = LinqChannel::new(
849 "tok".into(),
850 "+15551234567".into(),
851 "linq_test_alias",
852 Arc::new(|| vec!["*".into()]),
853 );
854 let payload = serde_json::json!({
855 "event_type": "message.received",
856 "data": {
857 "chat_id": "chat-789",
858 "from": "+1234567890",
859 "is_from_me": false,
860 "message": {
861 "id": "msg-abc",
862 "parts": [
863 { "type": "text", "value": "First part" },
864 { "type": "text", "value": "Second part" }
865 ]
866 }
867 }
868 });
869
870 let msgs = ch.parse_webhook_payload(&payload);
871 assert_eq!(msgs.len(), 1);
872 assert_eq!(msgs[0].content, "First part\nSecond part");
873 }
874
875 const TEST_WEBHOOK_SECRET: &str = "test_webhook_secret";
877
878 #[test]
879 fn linq_signature_verification_valid() {
880 let secret = TEST_WEBHOOK_SECRET;
881 let body = r#"{"event_type":"message.received"}"#;
882 let now = chrono::Utc::now().timestamp().to_string();
883
884 use hmac::{Hmac, Mac};
886 use sha2::Sha256;
887 let message = format!("{now}.{body}");
888 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
889 mac.update(message.as_bytes());
890 let signature = hex::encode(mac.finalize().into_bytes());
891
892 assert!(verify_linq_signature(secret, body, &now, &signature));
893 }
894
895 #[test]
896 fn linq_signature_verification_invalid() {
897 let secret = TEST_WEBHOOK_SECRET;
898 let body = r#"{"event_type":"message.received"}"#;
899 let now = chrono::Utc::now().timestamp().to_string();
900
901 assert!(!verify_linq_signature(
902 secret,
903 body,
904 &now,
905 "deadbeefdeadbeefdeadbeef"
906 ));
907 }
908
909 #[test]
910 fn linq_signature_verification_stale_timestamp() {
911 let secret = TEST_WEBHOOK_SECRET;
912 let body = r#"{"event_type":"message.received"}"#;
913 let stale_ts = (chrono::Utc::now().timestamp() - 600).to_string();
915
916 use hmac::{Hmac, Mac};
918 use sha2::Sha256;
919 let message = format!("{stale_ts}.{body}");
920 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
921 mac.update(message.as_bytes());
922 let signature = hex::encode(mac.finalize().into_bytes());
923
924 assert!(
925 !verify_linq_signature(secret, body, &stale_ts, &signature),
926 "Stale timestamps (>300s) should be rejected"
927 );
928 }
929
930 #[test]
931 fn linq_signature_verification_accepts_sha256_prefix() {
932 let secret = TEST_WEBHOOK_SECRET;
933 let body = r#"{"event_type":"message.received"}"#;
934 let now = chrono::Utc::now().timestamp().to_string();
935
936 use hmac::{Hmac, Mac};
937 use sha2::Sha256;
938 let message = format!("{now}.{body}");
939 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
940 mac.update(message.as_bytes());
941 let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
942
943 assert!(verify_linq_signature(secret, body, &now, &signature));
944 }
945
946 #[test]
947 fn linq_signature_verification_accepts_uppercase_hex() {
948 let secret = TEST_WEBHOOK_SECRET;
949 let body = r#"{"event_type":"message.received"}"#;
950 let now = chrono::Utc::now().timestamp().to_string();
951
952 use hmac::{Hmac, Mac};
953 use sha2::Sha256;
954 let message = format!("{now}.{body}");
955 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
956 mac.update(message.as_bytes());
957 let signature = hex::encode(mac.finalize().into_bytes()).to_ascii_uppercase();
958
959 assert!(verify_linq_signature(secret, body, &now, &signature));
960 }
961
962 #[test]
963 fn linq_parse_normalizes_phone_with_plus() {
964 let ch = LinqChannel::new(
965 "tok".into(),
966 "+15551234567".into(),
967 "linq_test_alias",
968 Arc::new(|| vec!["+1234567890".into()]),
969 );
970 let payload = serde_json::json!({
972 "event_type": "message.received",
973 "data": {
974 "chat_id": "chat-789",
975 "from": "1234567890",
976 "is_from_me": false,
977 "message": {
978 "id": "msg-abc",
979 "parts": [{ "type": "text", "value": "Hi" }]
980 }
981 }
982 });
983
984 let msgs = ch.parse_webhook_payload(&payload);
985 assert_eq!(msgs.len(), 1);
986 assert_eq!(msgs[0].sender, "+1234567890");
987 }
988
989 #[test]
990 fn linq_parse_missing_data() {
991 let ch = LinqChannel::new(
992 "test-token".into(),
993 "+15551234567".into(),
994 "linq_test_alias",
995 Arc::new(|| vec!["+1234567890".into()]),
996 );
997 let payload = serde_json::json!({
998 "event_type": "message.received"
999 });
1000 let msgs = ch.parse_webhook_payload(&payload);
1001 assert!(msgs.is_empty());
1002 }
1003
1004 #[test]
1005 fn linq_parse_missing_message_parts() {
1006 let ch = LinqChannel::new(
1007 "tok".into(),
1008 "+15551234567".into(),
1009 "linq_test_alias",
1010 Arc::new(|| vec!["*".into()]),
1011 );
1012 let payload = serde_json::json!({
1013 "event_type": "message.received",
1014 "data": {
1015 "chat_id": "chat-789",
1016 "from": "+1234567890",
1017 "is_from_me": false,
1018 "message": {
1019 "id": "msg-abc"
1020 }
1021 }
1022 });
1023
1024 let msgs = ch.parse_webhook_payload(&payload);
1025 assert!(msgs.is_empty());
1026 }
1027
1028 #[test]
1029 fn linq_parse_empty_text_value() {
1030 let ch = LinqChannel::new(
1031 "tok".into(),
1032 "+15551234567".into(),
1033 "linq_test_alias",
1034 Arc::new(|| vec!["*".into()]),
1035 );
1036 let payload = serde_json::json!({
1037 "event_type": "message.received",
1038 "data": {
1039 "chat_id": "chat-789",
1040 "from": "+1234567890",
1041 "is_from_me": false,
1042 "message": {
1043 "id": "msg-abc",
1044 "parts": [{ "type": "text", "value": "" }]
1045 }
1046 }
1047 });
1048
1049 let msgs = ch.parse_webhook_payload(&payload);
1050 assert!(msgs.is_empty(), "Empty text should be skipped");
1051 }
1052
1053 #[test]
1054 fn linq_parse_fallback_reply_target_when_no_chat_id() {
1055 let ch = LinqChannel::new(
1056 "tok".into(),
1057 "+15551234567".into(),
1058 "linq_test_alias",
1059 Arc::new(|| vec!["*".into()]),
1060 );
1061 let payload = serde_json::json!({
1062 "event_type": "message.received",
1063 "data": {
1064 "from": "+1234567890",
1065 "is_from_me": false,
1066 "message": {
1067 "id": "msg-abc",
1068 "parts": [{ "type": "text", "value": "Hi" }]
1069 }
1070 }
1071 });
1072
1073 let msgs = ch.parse_webhook_payload(&payload);
1074 assert_eq!(msgs.len(), 1);
1075 assert_eq!(msgs[0].reply_target, "+1234567890");
1077 }
1078
1079 #[test]
1080 fn linq_phone_number_accessor() {
1081 let ch = LinqChannel::new(
1082 "test-token".into(),
1083 "+15551234567".into(),
1084 "linq_test_alias",
1085 Arc::new(|| vec!["+1234567890".into()]),
1086 );
1087 assert_eq!(ch.phone_number(), "+15551234567");
1088 }
1089
1090 #[test]
1093 fn linq_parse_new_format_text_message() {
1094 let ch = LinqChannel::new(
1095 "test-token".into(),
1096 "+15551234567".into(),
1097 "linq_test_alias",
1098 Arc::new(|| vec!["+1234567890".into()]),
1099 );
1100 let payload = serde_json::json!({
1101 "api_version": "v3",
1102 "webhook_version": "2026-02-03",
1103 "event_type": "message.received",
1104 "event_id": "evt-123",
1105 "created_at": "2026-03-01T12:00:00Z",
1106 "trace_id": "trace-456",
1107 "data": {
1108 "id": "msg-abc",
1109 "direction": "inbound",
1110 "sender_handle": {
1111 "handle": "+1234567890",
1112 "is_me": false
1113 },
1114 "chat": { "id": "chat-789" },
1115 "service": "iMessage",
1116 "parts": [{
1117 "type": "text",
1118 "value": "Hello from new format!"
1119 }]
1120 }
1121 });
1122
1123 let msgs = ch.parse_webhook_payload(&payload);
1124 assert_eq!(msgs.len(), 1);
1125 assert_eq!(msgs[0].sender, "+1234567890");
1126 assert_eq!(msgs[0].content, "Hello from new format!");
1127 assert_eq!(msgs[0].channel, "linq");
1128 assert_eq!(msgs[0].reply_target, "chat-789");
1129 }
1130
1131 #[test]
1132 fn linq_parse_new_format_skip_is_me() {
1133 let ch = LinqChannel::new(
1134 "tok".into(),
1135 "+15551234567".into(),
1136 "linq_test_alias",
1137 Arc::new(|| vec!["*".into()]),
1138 );
1139 let payload = serde_json::json!({
1140 "event_type": "message.received",
1141 "webhook_version": "2026-02-03",
1142 "data": {
1143 "id": "msg-abc",
1144 "direction": "outbound",
1145 "sender_handle": {
1146 "handle": "+15551234567",
1147 "is_me": true
1148 },
1149 "chat": { "id": "chat-789" },
1150 "parts": [{ "type": "text", "value": "My own message" }]
1151 }
1152 });
1153
1154 let msgs = ch.parse_webhook_payload(&payload);
1155 assert!(
1156 msgs.is_empty(),
1157 "is_me messages should be skipped in new format"
1158 );
1159 }
1160
1161 #[test]
1162 fn linq_parse_new_format_skip_outbound_direction() {
1163 let ch = LinqChannel::new(
1164 "tok".into(),
1165 "+15551234567".into(),
1166 "linq_test_alias",
1167 Arc::new(|| vec!["*".into()]),
1168 );
1169 let payload = serde_json::json!({
1170 "event_type": "message.received",
1171 "webhook_version": "2026-02-03",
1172 "data": {
1173 "id": "msg-abc",
1174 "direction": "outbound",
1175 "sender_handle": {
1176 "handle": "+15551234567",
1177 "is_me": false
1178 },
1179 "chat": { "id": "chat-789" },
1180 "parts": [{ "type": "text", "value": "Outbound" }]
1181 }
1182 });
1183
1184 let msgs = ch.parse_webhook_payload(&payload);
1185 assert!(msgs.is_empty(), "outbound direction should be skipped");
1186 }
1187
1188 #[test]
1189 fn linq_parse_new_format_unauthorized_sender() {
1190 let ch = LinqChannel::new(
1191 "test-token".into(),
1192 "+15551234567".into(),
1193 "linq_test_alias",
1194 Arc::new(|| vec!["+1234567890".into()]),
1195 );
1196 let payload = serde_json::json!({
1197 "event_type": "message.received",
1198 "webhook_version": "2026-02-03",
1199 "data": {
1200 "id": "msg-abc",
1201 "direction": "inbound",
1202 "sender_handle": {
1203 "handle": "+9999999999",
1204 "is_me": false
1205 },
1206 "chat": { "id": "chat-789" },
1207 "parts": [{ "type": "text", "value": "Spam" }]
1208 }
1209 });
1210
1211 let msgs = ch.parse_webhook_payload(&payload);
1212 assert!(
1213 msgs.is_empty(),
1214 "Unauthorized senders should be filtered in new format"
1215 );
1216 }
1217
1218 #[test]
1219 fn linq_parse_new_format_media_image() {
1220 let ch = LinqChannel::new(
1221 "tok".into(),
1222 "+15551234567".into(),
1223 "linq_test_alias",
1224 Arc::new(|| vec!["*".into()]),
1225 );
1226 let payload = serde_json::json!({
1227 "event_type": "message.received",
1228 "webhook_version": "2026-02-03",
1229 "data": {
1230 "id": "msg-abc",
1231 "direction": "inbound",
1232 "sender_handle": {
1233 "handle": "+1234567890",
1234 "is_me": false
1235 },
1236 "chat": { "id": "chat-789" },
1237 "parts": [{
1238 "type": "media",
1239 "url": "https://example.com/photo.png",
1240 "mime_type": "image/png"
1241 }]
1242 }
1243 });
1244
1245 let msgs = ch.parse_webhook_payload(&payload);
1246 assert_eq!(msgs.len(), 1);
1247 assert_eq!(msgs[0].content, "[IMAGE:https://example.com/photo.png]");
1248 }
1249
1250 #[test]
1251 fn linq_parse_new_format_multiple_parts() {
1252 let ch = LinqChannel::new(
1253 "tok".into(),
1254 "+15551234567".into(),
1255 "linq_test_alias",
1256 Arc::new(|| vec!["*".into()]),
1257 );
1258 let payload = serde_json::json!({
1259 "event_type": "message.received",
1260 "webhook_version": "2026-02-03",
1261 "data": {
1262 "id": "msg-abc",
1263 "direction": "inbound",
1264 "sender_handle": {
1265 "handle": "+1234567890",
1266 "is_me": false
1267 },
1268 "chat": { "id": "chat-789" },
1269 "parts": [
1270 { "type": "text", "value": "Check this out" },
1271 { "type": "media", "url": "https://example.com/img.jpg", "mime_type": "image/jpeg" }
1272 ]
1273 }
1274 });
1275
1276 let msgs = ch.parse_webhook_payload(&payload);
1277 assert_eq!(msgs.len(), 1);
1278 assert_eq!(
1279 msgs[0].content,
1280 "Check this out\n[IMAGE:https://example.com/img.jpg]"
1281 );
1282 }
1283
1284 #[test]
1285 fn linq_parse_new_format_fallback_reply_target_when_no_chat() {
1286 let ch = LinqChannel::new(
1287 "tok".into(),
1288 "+15551234567".into(),
1289 "linq_test_alias",
1290 Arc::new(|| vec!["*".into()]),
1291 );
1292 let payload = serde_json::json!({
1293 "event_type": "message.received",
1294 "webhook_version": "2026-02-03",
1295 "data": {
1296 "id": "msg-abc",
1297 "direction": "inbound",
1298 "sender_handle": {
1299 "handle": "+1234567890",
1300 "is_me": false
1301 },
1302 "parts": [{ "type": "text", "value": "Hi" }]
1303 }
1304 });
1305
1306 let msgs = ch.parse_webhook_payload(&payload);
1307 assert_eq!(msgs.len(), 1);
1308 assert_eq!(msgs[0].reply_target, "+1234567890");
1309 }
1310
1311 #[test]
1312 fn linq_parse_new_format_normalizes_phone() {
1313 let ch = LinqChannel::new(
1314 "tok".into(),
1315 "+15551234567".into(),
1316 "linq_test_alias",
1317 Arc::new(|| vec!["+1234567890".into()]),
1318 );
1319 let payload = serde_json::json!({
1320 "event_type": "message.received",
1321 "webhook_version": "2026-02-03",
1322 "data": {
1323 "id": "msg-abc",
1324 "direction": "inbound",
1325 "sender_handle": {
1326 "handle": "1234567890",
1327 "is_me": false
1328 },
1329 "chat": { "id": "chat-789" },
1330 "parts": [{ "type": "text", "value": "Hi" }]
1331 }
1332 });
1333
1334 let msgs = ch.parse_webhook_payload(&payload);
1335 assert_eq!(msgs.len(), 1);
1336 assert_eq!(msgs[0].sender, "+1234567890");
1337 }
1338}