Skip to main content

zeroclaw_channels/
linq.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3use uuid::Uuid;
4use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
5
6/// Linq channel — uses the Linq Partner V3 API for iMessage, RCS, and SMS.
7///
8/// This channel operates in webhook mode (push-based) rather than polling.
9/// Messages are received via the gateway's `/linq` webhook endpoint.
10/// The `listen` method here is a keepalive placeholder; actual message handling
11/// happens in the gateway when Linq sends webhook events.
12pub struct LinqChannel {
13    api_token: String,
14    from_phone: String,
15    /// The alias key under `[channels.linq.<alias>]` this handle is
16    /// bound to. Used to scope peer-group writes and resolver lookups.
17    alias: String,
18    /// Resolves inbound external peers from canonical state at message-time.
19    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
20    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    /// Return the alias under `[channels.linq.<alias>]` that this
43    /// channel handle is bound to.
44    pub fn alias(&self) -> &str {
45        &self.alias
46    }
47
48    /// Check if a sender phone number is allowed (E.164 format: +1234567890)
49    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    /// Get the bot's phone number
55    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        // Legacy format: data.is_from_me
83        if let Some(v) = data.get("is_from_me").and_then(|value| value.as_bool()) {
84            return v;
85        }
86
87        // New format: data.sender_handle.is_me OR data.direction == "outbound"
88        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    /// Parse an incoming webhook payload from Linq and extract messages.
130    ///
131    /// Supports two webhook formats:
132    ///
133    /// **New format (webhook_version 2026-02-03):**
134    /// ```json
135    /// {
136    ///   "api_version": "v3",
137    ///   "webhook_version": "2026-02-03",
138    ///   "event_type": "message.received",
139    ///   "data": {
140    ///     "id": "msg-...",
141    ///     "direction": "inbound",
142    ///     "sender_handle": { "handle": "+1...", "is_me": false },
143    ///     "chat": { "id": "chat-..." },
144    ///     "parts": [{ "type": "text", "value": "..." }]
145    ///   }
146    /// }
147    /// ```
148    ///
149    /// **Legacy format (webhook_version 2025-01-01):**
150    /// ```json
151    /// {
152    ///   "api_version": "v3",
153    ///   "event_type": "message.received",
154    ///   "data": {
155    ///     "chat_id": "...",
156    ///     "from": "+1...",
157    ///     "is_from_me": false,
158    ///     "message": {
159    ///       "id": "...",
160    ///       "parts": [{ "type": "text", "value": "..." }]
161    ///     }
162    ///   }
163    /// }
164    /// ```
165    ///
166    /// Also accepts the current 2026-02-03 payload shape where `chat_id`,
167    /// `from`, `is_from_me`, and `message.parts` moved under:
168    /// `data.chat.id`, `data.sender_handle.handle`, `data.sender_handle.is_me`,
169    /// and `data.parts`.
170    pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
171        let mut messages = Vec::new();
172
173        // Only handle message.received events
174        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        // Skip messages sent by the bot itself
193        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        // Get sender phone number
203        let Some(from) = Self::sender_handle(data) else {
204            return messages;
205        };
206
207        // Normalize to E.164 format
208        let normalized_from = if from.starts_with('+') {
209            from.to_string()
210        } else {
211            format!("+{from}")
212        };
213
214        // Check allowlist
215        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        // Get chat_id for reply routing
227        let chat_id = Self::chat_id(data).unwrap_or("").to_string();
228
229        // Extract text from message parts
230        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        // Get timestamp from created_at or use current time
286        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        // Use chat_id as reply_target so replies go to the right conversation
302        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        // If reply_target looks like a chat_id, send to existing chat.
343        // Otherwise create a new chat with the recipient phone number.
344        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        // Try sending to existing chat (recipient is chat_id)
356        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 the chat_id-based send failed with 404, try creating a new chat
372        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        // Linq uses webhooks (push-based), not polling.
419        // Messages are received via the gateway's /linq endpoint.
420        ::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        // Keep the task alive — it will be cancelled when the channel shuts down
428        loop {
429            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
430        }
431    }
432
433    async fn health_check(&self) -> bool {
434        // Check if we can reach the Linq API
435        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
489/// Verify a Linq webhook signature.
490///
491/// Linq signs webhooks with HMAC-SHA256 over `"{timestamp}.{body}"`.
492/// The signature is sent in `X-Webhook-Signature` (hex-encoded) and the
493/// timestamp in `X-Webhook-Timestamp`. Reject timestamps older than 300s.
494pub fn verify_linq_signature(secret: &str, body: &str, timestamp: &str, signature: &str) -> bool {
495    use hmac::{Hmac, Mac};
496    use sha2::Sha256;
497
498    // Reject stale timestamps (>300s old)
499    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    // Compute HMAC-SHA256 over "{timestamp}.{body}"
523    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    // Constant-time comparison via HMAC verify.
543    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    /// Fixture secret used exclusively in signature-verification unit tests (not a real credential).
876    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        // Compute expected signature
885        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        // 10 minutes ago — stale
914        let stale_ts = (chrono::Utc::now().timestamp() - 600).to_string();
915
916        // Even with correct signature, stale timestamp should fail
917        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        // API sends without +, normalize to +
971        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        // Falls back to sender phone number when no chat_id
1076        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    // ---- New format (2026-02-03) tests ----
1091
1092    #[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}