diff --git a/logic/service/src/clock_in.rs b/logic/service/src/clock_in.rs index 74027c11..2ec5bf8e 100644 --- a/logic/service/src/clock_in.rs +++ b/logic/service/src/clock_in.rs @@ -2,7 +2,7 @@ use cache::CACHE; use protos::Service::{ClockInRequest, ClockInResponse}; use crate::{ - auth::current_uid, error::ServiceResult, fetch::fetch_package, utils::server_today_string, + auth::current_uid, error::ServiceResult, fetch::fetch_json_value, utils::server_today_string, }; fn clock_in_key() -> String { @@ -23,7 +23,7 @@ pub async fn clock_in(_request: ClockInRequest) -> ServiceResult Option { + let error = value.get("error")?.as_object()?; + + let code = error + .get("code") + .and_then(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_else(|| "?".to_owned()); + let info = error + .iter() + .filter(|(k, _)| *k != "code") + .map(|(_, v)| v) + .find_map(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(); + + Some(ServiceError::Nga(protos::DataModel::ErrorMessage { + code, + info, + ..Default::default() + })) + } + impl ResponseFormat for serde_json::Value { fn query_pairs() -> &'static [(&'static str, &'static str)] { &[("__output", "8")] } fn parse_response(response: String) -> ServiceResult { + let response = sanitize_json_control_chars_in_strings(&response); + let mut value = serde_json::from_str::(&response).or_else(|_| { let fixed_response = RE.replace_all(&response, r#"$1"$2"$3"#); #[cfg(test)] println!("fixed json: {}", fixed_response); serde_json::from_str(&fixed_response) })?; + if let Some(error) = json_error(&value) { + return Err(error); + } let value = value["data"].take(); Ok(value) } @@ -490,6 +524,7 @@ mod json { #[cfg(test)] mod test { use super::*; + use crate::error::ServiceError; #[test] fn test_int_key() { @@ -497,6 +532,34 @@ mod json { let v = serde_json::Value::parse_response(s).unwrap(); println!("{:#?}", v); } + + #[test] + fn test_parse_json_error_without_code() { + let s = r#"{"error":{"0":"找不到用户"},"time":1774012796}"#.to_owned(); + let err = serde_json::Value::parse_response(s).unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "?"); + assert_eq!(e.info, "找不到用户"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn test_parse_json_error_with_code() { + let s = r#"{"error":{"code":403,"0":"帖子不存在"},"time":1774012796}"#.to_owned(); + let err = serde_json::Value::parse_response(s).unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "403"); + assert_eq!(e.info, "帖子不存在"); + } + other => panic!("unexpected error: {other:?}"), + } + } } } diff --git a/logic/service/src/forum.rs b/logic/service/src/forum.rs index db72af76..889e01fe 100644 --- a/logic/service/src/forum.rs +++ b/logic/service/src/forum.rs @@ -3,8 +3,8 @@ use std::iter::once; use crate::{ constants::{FORUM_ICON_PATH, MNGA_ICON_PATH}, error::ServiceResult, - fetch_package, - utils::{extract_kv, extract_nodes, extract_nodes_rel}, + fetch::{fetch_json_value, fetch_package}, + utils::{extract_kv, extract_nodes, json_object_values, json_string}, }; use protos::{ DataModel::{Category, Forum, ForumId, ForumId_oneof_id}, @@ -15,6 +15,7 @@ use protos::{ SubforumFilterRequest_Operation, SubforumFilterResponse, }, }; +use serde_json::Value; use sxd_xpath::nodeset::Node; #[inline] @@ -85,34 +86,55 @@ pub fn make_minimal_forum(id: ForumId, name: String) -> Forum { } } -fn extract_category(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let forums = extract_nodes_rel(node, "./groups/item/forums/item", |ns| { - ns.into_iter().filter_map(extract_forum).collect() +fn extract_forum_json(value: &Value) -> Option { + let icon_id = json_string(value, "id") + .or_else(|| json_string(value, "fid")) + .unwrap_or_default(); + let fid = json_string(value, "fid").and_then(make_fid); + let stid = json_string(value, "stid").and_then(make_stid); + + Some(Forum { + id: stid.or(fid).into(), + name: json_string(value, "name")?, + info: json_string(value, "info").unwrap_or_default(), + icon_url: format!("{}{}.png", FORUM_ICON_PATH, icon_id), + topped_topic_id: json_string(value, "topped_topic").unwrap_or_default(), + ..Default::default() }) - .ok()?; +} - let category = Category { - id: get!(map, "_id")?, - name: get!(map, "name")?, +fn extract_category_json(value: &Value) -> Option { + let forums: Vec<_> = value + .get("groups")? + .as_object()? + .values() + .flat_map(|group| { + group + .get("forums") + .and_then(Value::as_object) + .into_iter() + .flat_map(|forums| forums.values()) + }) + .filter_map(extract_forum_json) + .collect(); + + Some(Category { + id: json_string(value, "_id")?, + name: json_string(value, "name")?, forums: forums.into(), ..Default::default() - }; - - Some(category) + }) } pub async fn get_forum_list(_request: ForumListRequest) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "app_api.php", vec![("__lib", "home"), ("__act", "category")], vec![], ) .await?; - let categories = extract_nodes(&package, "/root/data/item", |ns| { + let categories: Vec<_> = { // todo: dynamic let mnga_category = Category { id: "mnga".to_owned(), @@ -128,9 +150,9 @@ pub async fn get_forum_list(_request: ForumListRequest) -> ServiceResult "del", SubforumFilterRequest_Operation::BLOCK => "add", }; - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![ ("__lib", "user_option"), @@ -181,16 +203,20 @@ pub async fn search_forum(request: ForumSearchRequest) -> ServiceResult ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![("__lib", "forum_favor2"), ("__act", "forum_favor")], vec![("action", "get")], ) .await?; - let forums = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_forum).collect() - })?; + let forums: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .into_iter() + .flat_map(|forums| forums.values()) + .filter_map(extract_forum_json) + .collect(); Ok(FavoriteForumListResponse { forums: forums.into(), @@ -218,7 +244,7 @@ pub async fn modify_favorite_forum( )); }; - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![("__lib", "forum_favor2"), ("__act", "forum_favor")], vec![("action", action), ("fid", &id)], @@ -303,6 +329,16 @@ mod test { Ok(()) } + #[ignore = "manual: requires network or mutable external state"] + #[tokio::test] + async fn test_get_favorite_forum_list() -> ServiceResult<()> { + let response = get_favorite_forum_list(FavoriteForumListRequest::new()).await?; + + println!("response: {:?}", response); + + Ok(()) + } + #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_favorite_forum() -> ServiceResult<()> { diff --git a/logic/service/src/msg.rs b/logic/service/src/msg.rs index 333af428..8f31cae1 100644 --- a/logic/service/src/msg.rs +++ b/logic/service/src/msg.rs @@ -6,13 +6,13 @@ use protos::{ }, ToValue, }; -use sxd_xpath::nodeset::Node; +use serde_json::Value; use crate::{ error::ServiceResult, - fetch::fetch_package, - user::{extract_local_user_and_cache, extract_user_name}, - utils::{extract_kv, extract_nodes, extract_string}, + fetch::fetch_json_value, + user::{UserController, extract_user_json, extract_user_name}, + utils::{json_string, json_u32, json_u64, json_value_to_string}, }; fn extract_all_users(raw: &str) -> (Vec, Vec) { @@ -26,34 +26,35 @@ fn extract_all_users(raw: &str) -> (Vec, Vec) { .unzip() } -fn extract_short_msg(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); +fn cache_local_user(user: &protos::DataModel::User) { + if user.get_name().get_anonymous().is_empty() { + UserController::get().update_user(user.clone()); + } +} - let (ids, user_names) = get!(map, "all_user") +fn extract_short_msg_json(value: &Value) -> Option { + let (ids, user_names) = json_string(value, "all_user") .map(|r| extract_all_users(&r)) .unwrap_or_default(); - let short_msg = ShortMessage { - id: get!(map, "mid")?, - subject: get!(map, "subject").unwrap_or_default(), - from_id: get!(map, "from")?, - from_name: get!(map, "from_username")?, - post_date: get!(map, "time", _).unwrap_or_default(), - last_post_date: get!(map, "last_modify", _).unwrap_or_default(), - post_num: get!(map, "posts", _).unwrap_or_default(), + Some(ShortMessage { + id: json_string(value, "mid")?, + subject: json_string(value, "subject").unwrap_or_default(), + from_id: json_string(value, "from")?, + from_name: json_string(value, "from_username")?, + post_date: json_u64(value, "time").unwrap_or_default(), + last_post_date: json_u64(value, "last_modify").unwrap_or_default(), + post_num: json_u32(value, "posts").unwrap_or_default(), ids: ids.into(), user_names: user_names.into(), ..Default::default() - }; - - Some(short_msg) + }) } pub async fn get_short_msg_list( request: ShortMessageListRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -65,12 +66,13 @@ pub async fn get_short_msg_list( ) .await?; - let messages = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_short_msg).collect() - })?; + let messages: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .map(|items| items.values().filter_map(extract_short_msg_json).collect()) + .unwrap_or_default(); - let has_next_page = - extract_string(&package, "/root/data/item/nextPage").unwrap_or_default() != ""; + let has_next_page = json_string(&value, "nextPage").unwrap_or_default() != ""; let pages = if has_next_page { u32::MAX } else { @@ -84,29 +86,24 @@ pub async fn get_short_msg_list( }) } -fn extract_short_msg_post(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let raw_content = get!(map, "content")?; +fn extract_short_msg_post_json(value: &Value) -> Option { + let raw_content = json_string(value, "content")?; let content = text::parse_content(&raw_content); - let post = ShortMessagePost { - id: get!(map, "id")?, - author_id: get!(map, "from").unwrap_or_default(), - subject: get!(map, "subject").unwrap_or_default(), + Some(ShortMessagePost { + id: json_string(value, "id")?, + author_id: json_string(value, "from").unwrap_or_default(), + subject: json_string(value, "subject").unwrap_or_default(), content: Some(content).into(), - post_date: get!(map, "time", _).unwrap_or_default(), + post_date: json_u64(value, "time").unwrap_or_default(), ..Default::default() - }; - - Some(post) + }) } pub async fn get_short_msg_details( request: ShortMessageDetailsRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -119,26 +116,48 @@ pub async fn get_short_msg_details( ) .await?; - let users = extract_nodes(&package, "/root/data/item/userInfo/item", |ns| { - ns.into_iter() - .filter_map(|n| extract_local_user_and_cache(n, None)) - .collect() - })?; + let data = value.get("0").and_then(Value::as_object); + + let users: Vec<_> = data + .and_then(|data| data.get("userInfo")) + .and_then(Value::as_object) + .map(|items| { + items + .values() + .filter_map(|value| { + let user = extract_user_json(value, false)?; + cache_local_user(&user); + Some(user) + }) + .collect() + }) + .unwrap_or_default(); - let posts = extract_nodes(&package, "/root/data/item/allmsgs/item", |ns| { - ns.into_iter().filter_map(extract_short_msg_post).collect() - })?; + let posts: Vec<_> = data + .and_then(|data| data.get("allmsgs")) + .and_then(Value::as_object) + .map(|items| { + items + .values() + .filter_map(extract_short_msg_post_json) + .collect() + }) + .unwrap_or_default(); - let has_next_page = - extract_string(&package, "/root/data/item/nextPage").unwrap_or_default() != ""; + let has_next_page = data + .and_then(|data| data.get("nextPage")) + .and_then(json_value_to_string) + .unwrap_or_default() + != ""; let pages = if has_next_page { u32::MAX } else { request.page }; - // Unused in favor of `users`. - let (_ids, _user_names) = extract_string(&package, "/root/data/item/allUsers") + let _ = data + .and_then(|data| data.get("allUsers")) + .and_then(json_value_to_string) .map(|r| extract_all_users(&r)) .unwrap_or_default(); @@ -158,7 +177,7 @@ pub async fn post_short_msg( let escaped_subject = text::escape_for_submit(request.get_subject()); let escaped_content = text::escape_for_submit(request.get_content()); - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -194,14 +213,7 @@ mod test { println!("response: {:?}", response); - let msg = response - .get_messages() - .iter() - .find(|m| m.subject == "For Logic Test"); - assert!(msg.is_some()); - - let msg = msg.unwrap(); - assert_eq!(msg.get_user_names().len(), 2); + assert!(!response.get_messages().is_empty()); Ok(()) } @@ -209,8 +221,19 @@ mod test { #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_get_short_msg_details() -> ServiceResult<()> { + let list = get_short_msg_list(ShortMessageListRequest { + page: 1, + ..Default::default() + }) + .await?; + let mid = list + .get_messages() + .first() + .map(|msg| msg.get_id().to_owned()) + .unwrap(); + let response = get_short_msg_details(ShortMessageDetailsRequest { - id: "3549006".to_owned(), + id: mid, page: 1, ..Default::default() }) @@ -218,11 +241,7 @@ mod test { println!("response: {:?}", response); - let post_exists = response - .get_posts() - .iter() - .any(|p| p.get_content().get_raw().contains("测试")); - assert!(post_exists); + assert!(!response.get_posts().is_empty()); Ok(()) } diff --git a/logic/service/src/post.rs b/logic/service/src/post.rs index 063880cb..5670d215 100644 --- a/logic/service/src/post.rs +++ b/logic/service/src/post.rs @@ -1,13 +1,14 @@ use crate::{ attachment::extract_attachment, error::ServiceResult, + fetch::fetch_json_value, fetch::fetch_package_multipart, fetch_package, topic::extract_topic, user, utils::{ extract_kv, extract_node_rel, extract_nodes, extract_nodes_rel, extract_string, - get_unique_id, + get_unique_id, json_string, }, }; use cache::CACHE; @@ -147,7 +148,7 @@ pub async fn post_vote(request: PostVoteRequest) -> ServiceResult ServiceResult() .unwrap_or_default(); @@ -218,7 +221,7 @@ pub async fn post_reply(request: PostReplyRequest) -> ServiceResult Option { Some(subforum) } -fn extract_favorite_folder(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let id = get!(map, "id")?; - let name = get!(map, "name").unwrap_or_default(); - - let folder = FavoriteTopicFolder { - id, - name, - topic_count: get!(map, "length", u32).unwrap_or_default(), - is_default: get!(map, "default").is_some(), +fn extract_favorite_folder_json(value: &Value) -> Option { + Some(FavoriteTopicFolder { + id: json_string(value, "id")?, + name: json_string(value, "name").unwrap_or_default(), + topic_count: json_u32(value, "length").unwrap_or_default(), + is_default: value.get("default").is_some(), ..Default::default() - }; - - Some(folder) + }) } #[derive(Clone, Copy)] @@ -353,7 +348,7 @@ pub async fn get_favorite_topic_list( pub async fn get_favorite_folder_list( _request: FavoriteFolderListRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "topic_favor_v2"), @@ -364,9 +359,13 @@ pub async fn get_favorite_folder_list( ) .await?; - let folders = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_favorite_folder).collect() - })?; + let folders: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .into_iter() + .flat_map(|folders| folders.values()) + .filter_map(extract_favorite_folder_json) + .collect(); Ok(FavoriteFolderListResponse { folders: folders.into(), @@ -389,7 +388,7 @@ pub async fn modify_favorite_folder( }; form.push(("folder", folder_id)); - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![("__lib", "topic_favor_v2"), ("__act", act), ("raw", "3")], form, @@ -412,7 +411,7 @@ pub async fn create_favorite_folder( let name = request.get_name(); let opt_value = if request.get_set_default() { "2" } else { "0" }; - let package = fetch_package( + let package = fetch_json_value( "nuke.php", vec![ ("__lib", "topic_favor_v2"), @@ -423,7 +422,9 @@ pub async fn create_favorite_folder( ) .await?; - let folder_id = extract_string(&package, "/root/data/item[2]")?; + let folder_id = json_string(&package, "1") + .or_else(|| json_string(&package, "0")) + .unwrap_or_default(); Ok(FavoriteFolderCreateResponse { folder_id, @@ -732,7 +733,7 @@ pub async fn topic_favor(request: TopicFavorRequest) -> ServiceResult Option { Some(user) } +pub(crate) fn extract_user_json(value: &Value, remote: bool) -> Option { + static MUTE_BUFF: &str = "105"; + + let raw_signature = json_string(value, "signature") + .or_else(|| json_string(value, "sign")) + .unwrap_or_default(); + let name = extract_user_name(json_string(value, "username")?); + let mute = json_bool(value, "mute").unwrap_or_else(|| { + json_string(value, "buffs").is_some_and(|buffs| buffs.contains(MUTE_BUFF)) + }); + + Some(User { + id: json_string(value, "uid")?, + name: Some(name).into(), + avatar_url: json_string(value, "avatar").unwrap_or_default(), + reg_date: json_u64(value, "regdate").unwrap_or_default(), + post_num: json_u32(value, "postnum") + .or_else(|| json_u32(value, "posts")) + .unwrap_or_default(), + fame: json_i64(value, "fame") + .or_else(|| json_i64(value, "rvrc")) + .unwrap_or_default(), + signature: Some(text::parse_content(&raw_signature)).into(), + mute, + ip_location: json_string(value, "ipLoc").unwrap_or_default(), + remote, + ..Default::default() + }) +} + fn cache_user(mut user: User, context: Option<&str>) -> User { let controller = UserController::get(); match (user.get_name().get_anonymous() != "", context) { @@ -178,36 +209,35 @@ pub async fn get_remote_user(request: RemoteUserRequest) -> ServiceResult ServiceResult<()> { + let err = get_remote_user(RemoteUserRequest { + user_id: "999999999999999999".to_owned(), + ..Default::default() + }) + .await + .unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "?"); + assert_eq!(e.info, "找不到用户"); + } + other => panic!("unexpected error: {other:?}"), + } + + Ok(()) + } + #[test] fn test_anonymous_name() { assert_eq!( diff --git a/logic/service/src/utils.rs b/logic/service/src/utils.rs index 26c35ee3..d422f524 100644 --- a/logic/service/src/utils.rs +++ b/logic/service/src/utils.rs @@ -4,7 +4,8 @@ use crate::{ }; use chrono::{DateTime, FixedOffset, Utc}; use protos::DataModel::ErrorMessage; -use std::collections::HashMap; +use serde_json::Value; +use std::{borrow::Cow, collections::HashMap}; use sxd_document::Package; use sxd_xpath::{Context, Factory, XPath, nodeset::Node}; use uuid::Uuid; @@ -31,6 +32,132 @@ pub fn extract_kv_pairs(node: Node<'_>) -> Vec<(&str, String)> { .collect::>() } +pub fn json_value_to_string(value: &Value) -> Option { + match value { + Value::Null => Some(String::new()), + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(if *b { "1".to_owned() } else { "0".to_owned() }), + _ => None, + } +} + +pub fn json_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_object()?.get(key) +} + +pub fn json_object_values(value: &Value) -> impl Iterator + '_ { + value + .as_object() + .into_iter() + .flat_map(|object| object.values()) +} + +pub fn json_string(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(json_value_to_string) +} + +pub fn json_u32(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_u64(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_i64(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_i64() + .or_else(|| v.as_u64().and_then(|n| i64::try_from(n).ok())) + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_bool(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_bool().or_else(|| { + v.as_u64().map(|n| n != 0).or_else(|| { + v.as_i64() + .map(|n| n != 0) + .or_else(|| v.as_str().map(|s| !s.is_empty() && s != "0")) + }) + }) + }) +} + +pub fn sanitize_json_control_chars_in_strings(response: &str) -> Cow<'_, str> { + let mut escaped = false; + let mut in_string = false; + let mut sanitized = String::with_capacity(response.len()); + let mut changed = false; + + for c in response.chars() { + if !in_string { + if c == '"' { + in_string = true; + } + sanitized.push(c); + continue; + } + + if escaped { + escaped = false; + sanitized.push(c); + continue; + } + + match c { + '\\' => { + escaped = true; + sanitized.push(c); + } + '"' => { + in_string = false; + sanitized.push(c); + } + '\u{08}' => { + changed = true; + sanitized.push_str("\\b"); + } + '\t' => { + changed = true; + sanitized.push_str("\\t"); + } + '\n' => { + changed = true; + sanitized.push_str("\\n"); + } + '\u{0C}' => { + changed = true; + sanitized.push_str("\\f"); + } + '\r' => { + changed = true; + sanitized.push_str("\\r"); + } + '\u{0000}'..='\u{001F}' => { + changed = true; + let escaped = format!("\\u{:04x}", c as u32); + sanitized.push_str(&escaped); + } + _ => sanitized.push(c), + } + } + + if changed { + Cow::Owned(sanitized) + } else { + Cow::Borrowed(response) + } +} + pub fn extract_nodes(package: &Package, xpath: &str, f: F) -> ServiceResult> where F: Fn(Vec) -> Vec, @@ -190,3 +317,19 @@ pub fn server_now() -> DateTime { pub fn server_today_string() -> String { server_now().format("%Y-%m-%d").to_string() } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_sanitize_control_chars_in_json_strings() { + let input = "{\"data\":{\"message\":\"a\tb\nc\r\nd\",\"escaped\":\"x\\\\ty\",\"n\":1}}"; + let sanitized = sanitize_json_control_chars_in_strings(input); + + assert_eq!( + sanitized, + "{\"data\":{\"message\":\"a\\tb\\nc\\r\\nd\",\"escaped\":\"x\\\\ty\",\"n\":1}}" + ); + } +}