diff --git a/native/swift/Sources/wordpress-api/WPComExtensions.swift b/native/swift/Sources/wordpress-api/WPComExtensions.swift new file mode 100644 index 000000000..33f2009ef --- /dev/null +++ b/native/swift/Sources/wordpress-api/WPComExtensions.swift @@ -0,0 +1,21 @@ +import WordPressAPIInternal + +public extension BotMessageContext { + var userWantsToTalkToAHuman: Bool { + WordPressAPIInternal.userWantsToTalkToAHuman(context: self) + } +} + +public extension BotConversation { + var userWantsToTalkToAHuman: Bool { + for message in self.messages { + if case .bot(let botContext) = message.context { + if botContext.userWantsToTalkToAHuman { + return true + } + } + } + + return false + } +} diff --git a/wp_api/src/wp_com/support_bots.rs b/wp_api/src/wp_com/support_bots.rs index 433725d5c..8f9bd6886 100644 --- a/wp_api/src/wp_com/support_bots.rs +++ b/wp_api/src/wp_com/support_bots.rs @@ -7,6 +7,7 @@ use crate::{ use serde::{Deserialize, Serialize}; use serde_repr::*; use std::collections::HashMap; +use wp_serde_helper::deserialize_empty_vec_or_none; use super::WpComSiteId; @@ -54,6 +55,14 @@ pub struct BotMessageContext { pub flags: HashMap, } +#[uniffi::export] +pub fn user_wants_to_talk_to_a_human(context: &BotMessageContext) -> bool { + *context + .flags + .get("forward_to_human_support") + .unwrap_or(&false) +} + #[derive(Debug, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct BotMessageContextSource { pub title: String, @@ -70,11 +79,12 @@ pub struct BotMessageContextSource { #[serde(rename_all = "snake_case")] pub struct UserMessageContext { #[serde(alias = "selectedSiteId")] - pub selected_site_id: WpComSiteId, + pub selected_site_id: Option, pub wpcom_user_id: UserId, pub wpcom_user_name: String, pub user_paid_support_eligibility: UserPaidSupportEligibility, - pub plan: UserPaidSupportPlan, + #[serde(deserialize_with = "deserialize_empty_vec_or_none")] + pub plan: Option, pub products: Vec, pub plan_interface: bool, } @@ -199,6 +209,9 @@ impl std::fmt::Display for MessageId { #[cfg(test)] mod tests { + use rstest::rstest; + use std::io::Read; + use super::*; #[test] @@ -209,12 +222,17 @@ mod tests { assert_eq!(conversation.chat_id, 1965886); } - #[test] - fn test_bot_conversation_deserialization() { - let json = include_str!("../../tests/wpcom/support_bots/single-conversation.json"); - let conversation: BotConversation = - serde_json::from_str(json).expect("Failed to deserialize bot conversation"); - assert_eq!(conversation.chat_id, 1965758); + #[rstest] + #[case("single-conversation-01.json", 1965758)] + #[case("single-conversation-02.json", 1234567)] + fn test_bot_conversation_deserialization( + #[case] json_file_path: &str, + #[case] expected_chat_id: u64, + ) { + let json = test_json(json_file_path).expect("Failed to read JSON file"); + let conversation: BotConversation = serde_json::from_slice(json.as_slice()) + .expect("Failed to deserialize bot conversation"); + assert_eq!(conversation.chat_id, expected_chat_id); } #[test] @@ -235,4 +253,21 @@ mod tests { serde_json::from_str(json).expect("Failed to deserialize bot conversation"); assert_eq!(conversation.chat_id, 1965758); } + + fn test_json(input: &str) -> Result, std::io::Error> { + let mut file_path = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + file_path.push("wp_api"); + file_path.push("tests"); + file_path.push("wpcom"); + file_path.push("support_bots"); + file_path.push(input); + + let mut f = std::fs::File::open(file_path)?; + let mut buffer = Vec::new(); + + // read the whole file + f.read_to_end(&mut buffer)?; + + Ok(buffer) + } } diff --git a/wp_api/tests/wpcom/support_bots/single-conversation.json b/wp_api/tests/wpcom/support_bots/single-conversation-01.json similarity index 100% rename from wp_api/tests/wpcom/support_bots/single-conversation.json rename to wp_api/tests/wpcom/support_bots/single-conversation-01.json diff --git a/wp_api/tests/wpcom/support_bots/single-conversation-02.json b/wp_api/tests/wpcom/support_bots/single-conversation-02.json new file mode 100644 index 000000000..6bc012536 --- /dev/null +++ b/wp_api/tests/wpcom/support_bots/single-conversation-02.json @@ -0,0 +1,34 @@ +{ + "chat_id": 1234567, + "wpcom_user_id": 987654, + "external_id": null, + "external_id_provider": null, + "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "bot_slug": "jetpack-chat-mobile", + "bot_version": "2.0.0", + "created_at": "2025-10-03 02:01:05", + "zendesk_ticket_id": null, + "messages": [ + { + "message_id": 2000001, + "content": "This is a test - is it working?", + "role": "user", + "created_at": "2025-10-03 02:01:05", + "context": { + "message": "This is a test - is it working?", + "wpcom_user_id": 987654, + "wpcom_user_name": "testuser", + "user_paid_support_eligibility": { + "is_user_eligible": true, + "wapuu_assistant_enabled": true, + "support_level": "p2-plus", + "user_field_flow_name": null + }, + "plan": [], + "products": [], + "plan_interface": false + }, + "ts": 1609459200 + } + ] + } diff --git a/wp_serde_helper/src/lib.rs b/wp_serde_helper/src/lib.rs index f5a0892f1..411458e95 100644 --- a/wp_serde_helper/src/lib.rs +++ b/wp_serde_helper/src/lib.rs @@ -3,6 +3,7 @@ use serde::{ de::{self, DeserializeOwned, Unexpected}, ser, }; +use serde_json::Value; use std::{fmt, marker::PhantomData}; pub use wp_serde_date::wp_utc_date_format; @@ -360,6 +361,56 @@ impl<'de> de::Visitor<'de> for DeserializeStringVecOrStringAsOptionVisitor { } } +pub fn ok_or_default<'a, T, D>(deserializer: D) -> Result +where + T: Deserialize<'a> + Default, + D: Deserializer<'a>, +{ + let v: Value = Deserialize::deserialize(deserializer)?; + Ok(T::deserialize(v).unwrap_or_default()) +} + +struct DeserializeEmptyVecOrNone(PhantomData); + +impl<'de, T> de::Visitor<'de> for DeserializeEmptyVecOrNone +where + T: Deserialize<'de>, +{ + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("empty Vec or T") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: de::SeqAccess<'de>, + { + if seq.next_element::()?.is_none() { + // It's an empty vec + Ok(None) + } else { + // not an empty vec + Err(serde::de::Error::invalid_type(Unexpected::Seq, &self)) + } + } + + fn visit_map(self, map: A) -> Result + where + A: de::MapAccess<'de>, + { + T::deserialize(de::value::MapAccessDeserializer::new(map)).map(Some) + } +} + +pub fn deserialize_empty_vec_or_none<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + deserializer.deserialize_any(DeserializeEmptyVecOrNone::(PhantomData)) +} + #[cfg(test)] mod tests { use super::*; @@ -491,4 +542,21 @@ mod tests { serde_json::from_str(test_case).expect("Test case should be a valid JSON"); assert_eq!(expected_result, option_string_vec_or_string.string); } + + #[derive(Debug, Deserialize)] + pub struct OptionStringOrBool { + #[serde(deserialize_with = "ok_or_default")] + pub value: Option, + } + + #[rstest] + #[case(r#"{"value": "foo"}"#, Some("foo".to_string()))] + #[case(r#"{"value": "false"}"#, Some("false".to_string()))] + #[case(r#"{"value": false}"#, None)] + #[case(r#"{"value": []}"#, None)] + fn test_ok_or_default(#[case] test_case: &str, #[case] expected_result: Option) { + let option_string_or_bool: OptionStringOrBool = + serde_json::from_str(test_case).expect("Test case should be a valid JSON"); + assert_eq!(expected_result, option_string_or_bool.value); + } }