diff --git a/src/channels/linq.rs b/src/channels/linq.rs index 214f68427..bc6247b28 100644 --- a/src/channels/linq.rs +++ b/src/channels/linq.rs @@ -59,6 +59,54 @@ impl LinqChannel { Some(format!("[IMAGE:{source}]")) } + fn sender_is_from_me(data: &serde_json::Value) -> bool { + // Legacy format: data.is_from_me + if let Some(v) = data.get("is_from_me").and_then(|value| value.as_bool()) { + return v; + } + + // New format: data.sender_handle.is_me OR data.direction == "outbound" + let is_me = data + .get("sender_handle") + .and_then(|value| value.get("is_me")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let is_outbound = matches!( + data.get("direction").and_then(|value| value.as_str()), + Some("outbound") + ); + + is_me || is_outbound + } + + fn sender_handle(data: &serde_json::Value) -> Option<&str> { + data.get("from") + .and_then(|value| value.as_str()) + .or_else(|| { + data.get("sender_handle") + .and_then(|value| value.get("handle")) + .and_then(|value| value.as_str()) + }) + } + + fn chat_id(data: &serde_json::Value) -> Option<&str> { + data.get("chat_id") + .and_then(|value| value.as_str()) + .or_else(|| { + data.get("chat") + .and_then(|value| value.get("id")) + .and_then(|value| value.as_str()) + }) + } + + fn message_parts(data: &serde_json::Value) -> Option<&Vec> { + data.get("message") + .and_then(|value| value.get("parts")) + .and_then(|value| value.as_array()) + .or_else(|| data.get("parts").and_then(|value| value.as_array())) + } + /// Parse an incoming webhook payload from Linq and extract messages. /// /// Supports two webhook formats: @@ -95,6 +143,11 @@ impl LinqChannel { /// } /// } /// ``` + /// + /// Also accepts the current 2026-02-03 payload shape where `chat_id`, + /// `from`, `is_from_me`, and `message.parts` moved under: + /// `data.chat.id`, `data.sender_handle.handle`, `data.sender_handle.is_me`, + /// and `data.parts`. pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec { let mut messages = Vec::new(); @@ -112,44 +165,14 @@ impl LinqChannel { return messages; }; - // Detect format: new format has `sender_handle`, legacy has `from`. - let is_new_format = data.get("sender_handle").is_some(); - // Skip messages sent by the bot itself - let is_from_me = if is_new_format { - // New format: data.sender_handle.is_me or data.direction == "outbound" - data.get("sender_handle") - .and_then(|sh| sh.get("is_me")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) - || data - .get("direction") - .and_then(|d| d.as_str()) - .is_some_and(|d| d == "outbound") - } else { - // Legacy format: data.is_from_me - data.get("is_from_me") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - }; - - if is_from_me { + if Self::sender_is_from_me(data) { tracing::debug!("Linq: skipping is_from_me message"); return messages; } // Get sender phone number - let from = if is_new_format { - // New format: data.sender_handle.handle - data.get("sender_handle") - .and_then(|sh| sh.get("handle")) - .and_then(|h| h.as_str()) - } else { - // Legacy format: data.from - data.get("from").and_then(|f| f.as_str()) - }; - - let Some(from) = from else { + let Some(from) = Self::sender_handle(data) else { return messages; }; @@ -171,33 +194,10 @@ impl LinqChannel { } // Get chat_id for reply routing - let chat_id = if is_new_format { - // New format: data.chat.id - data.get("chat") - .and_then(|c| c.get("id")) - .and_then(|id| id.as_str()) - .unwrap_or("") - .to_string() - } else { - // Legacy format: data.chat_id - data.get("chat_id") - .and_then(|c| c.as_str()) - .unwrap_or("") - .to_string() - }; + let chat_id = Self::chat_id(data).unwrap_or("").to_string(); - // Extract message parts - let parts = if is_new_format { - // New format: data.parts (directly on data) - data.get("parts").and_then(|p| p.as_array()) - } else { - // Legacy format: data.message.parts - data.get("message") - .and_then(|m| m.get("parts")) - .and_then(|p| p.as_array()) - }; - - let Some(parts) = parts else { + // Extract text from message parts + let Some(parts) = Self::message_parts(data) else { return messages; }; @@ -520,6 +520,42 @@ mod tests { assert_eq!(msgs[0].reply_target, "chat-789"); } + #[test] + fn linq_parse_latest_webhook_shape() { + let ch = LinqChannel::new( + "tok".into(), + "+15551234567".into(), + vec!["+1234567890".into()], + ); + let payload = serde_json::json!({ + "api_version": "v3", + "webhook_version": "2026-02-03", + "event_type": "message.received", + "created_at": "2026-02-03T12:00:00Z", + "data": { + "chat": { + "id": "chat-2026" + }, + "direction": "inbound", + "id": "msg-2026", + "parts": [{ + "type": "text", + "value": "Hello from the latest payload" + }], + "sender_handle": { + "handle": "1234567890", + "is_me": false + } + } + }); + + let msgs = ch.parse_webhook_payload(&payload); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].sender, "+1234567890"); + assert_eq!(msgs[0].content, "Hello from the latest payload"); + assert_eq!(msgs[0].reply_target, "chat-2026"); + } + #[test] fn linq_parse_skip_is_from_me() { let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]); @@ -540,6 +576,34 @@ mod tests { assert!(msgs.is_empty(), "is_from_me messages should be skipped"); } + #[test] + fn linq_parse_skip_latest_outbound_message() { + let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]); + let payload = serde_json::json!({ + "event_type": "message.received", + "data": { + "chat": { + "id": "chat-789" + }, + "direction": "outbound", + "parts": [{ + "type": "text", + "value": "My own message" + }], + "sender_handle": { + "handle": "+1234567890", + "is_me": true + } + } + }); + + let msgs = ch.parse_webhook_payload(&payload); + assert!( + msgs.is_empty(), + "latest outbound messages from the bot should be skipped" + ); + } + #[test] fn linq_parse_skip_non_message_event() { let ch = make_channel();