Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions native/swift/Sources/wordpress-api/WPComExtensions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
51 changes: 43 additions & 8 deletions wp_api/src/wp_com/support_bots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -54,6 +55,14 @@ pub struct BotMessageContext {
pub flags: HashMap<String, bool>,
}

#[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,
Expand All @@ -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<WpComSiteId>,
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<UserPaidSupportPlan>,
pub products: Vec<String>,
pub plan_interface: bool,
}
Expand Down Expand Up @@ -199,6 +209,9 @@ impl std::fmt::Display for MessageId {

#[cfg(test)]
mod tests {
use rstest::rstest;
use std::io::Read;

use super::*;

#[test]
Expand All @@ -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]
Expand All @@ -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<Vec<u8>, 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)
}
}
34 changes: 34 additions & 0 deletions wp_api/tests/wpcom/support_bots/single-conversation-02.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
68 changes: 68 additions & 0 deletions wp_serde_helper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -360,6 +361,56 @@ impl<'de> de::Visitor<'de> for DeserializeStringVecOrStringAsOptionVisitor {
}
}

pub fn ok_or_default<'a, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: Deserialize<'a> + Default,
D: Deserializer<'a>,
{
let v: Value = Deserialize::deserialize(deserializer)?;
Ok(T::deserialize(v).unwrap_or_default())
}

struct DeserializeEmptyVecOrNone<T>(PhantomData<T>);

impl<'de, T> de::Visitor<'de> for DeserializeEmptyVecOrNone<T>
where
T: Deserialize<'de>,
{
type Value = Option<T>;

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("empty Vec or T")
}

fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
if seq.next_element::<T>()?.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<A>(self, map: A) -> Result<Self::Value, A::Error>
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<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
deserializer.deserialize_any(DeserializeEmptyVecOrNone::<T>(PhantomData))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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<String>,
}

#[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<String>) {
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);
}
}