From 39d788a95fe3c498d606f3799dd9b3d6f3edbd37 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Sat, 14 Mar 2026 09:53:41 +0800
Subject: [PATCH] fix(linq): accept latest webhook payload shape (#3351)
* fix(linq): accept current webhook payload shape
* style(linq): satisfy clippy lifetime lint
---------
Co-authored-by: argenis de la rosa
---
src/channels/linq.rs | 180 +++++++++++++++++++++++++++++--------------
1 file changed, 122 insertions(+), 58 deletions(-)
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();