lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 3c4db5b84bdea7c30d7cadfe07e0e9f370422c43
parent f043868a55b261553827ad3a9cfe7c41895c9280
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 17:24:17 +0000

simplex: add chat protocol codec crate

- add the radroots-simplex-chat-proto crate with direct-chat JSON codecs
- preserve unknown events and unknown content for official client compatibility
- implement official X-compressed batch handling with synthetic interop tests
- align workspace contract metadata and lockfile entries for the new crate

Diffstat:
MCargo.lock | 50++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 3+++
Mcontract/coverage/policy.toml | 2++
Mcontract/release/publish-set.toml | 4+++-
Acrates/simplex-chat-proto/Cargo.toml | 24++++++++++++++++++++++++
Acrates/simplex-chat-proto/src/codec.rs | 924+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-chat-proto/src/error.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-chat-proto/src/lib.rs | 39+++++++++++++++++++++++++++++++++++++++
Acrates/simplex-chat-proto/src/model.rs | 701+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-chat-proto/src/version.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-chat-proto/tests/chat_proto.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 2174 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -347,6 +347,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1386,6 +1388,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] name = "js-sys" version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2400,6 +2412,16 @@ dependencies = [ ] [[package]] +name = "radroots-simplex-chat-proto" +version = "0.1.0-alpha.1" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", + "zstd", +] + +[[package]] name = "radroots-simplex-smp-proto" version = "0.1.0-alpha.1" dependencies = [ @@ -4462,3 +4484,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/nostr-ndb", "crates/nostr-runtime", "crates/runtime", + "crates/simplex-chat-proto", "crates/simplex-smp-proto", "crates/sql-wasm-bridge", "crates/sql-wasm-core", @@ -59,6 +60,7 @@ radroots-log = { path = "crates/log", version = "0.1.0-alpha.1", default-feature radroots-net = { path = "crates/net", version = "0.1.0-alpha.1", default-features = false } radroots-net-core = { path = "crates/net-core", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-runtime = { path = "crates/nostr-runtime", version = "0.1.0-alpha.1", default-features = false } +radroots-simplex-chat-proto = { path = "crates/simplex-chat-proto", version = "0.1.0-alpha.1", default-features = false } radroots-simplex-smp-proto = { path = "crates/simplex-smp-proto", version = "0.1.0-alpha.1", default-features = false } radroots-sql-wasm-bridge = { path = "crates/sql-wasm-bridge", version = "0.1.0-alpha.1" } radroots-sql-wasm-core = { path = "crates/sql-wasm-core", version = "0.1.0-alpha.1", default-features = false } @@ -116,6 +118,7 @@ ts-rs = { version = "11.1" } typeshare = { version = "1" } url = { version = "2" } uuid = { version = "1.22.0", features = ["v4", "v7"] } +zstd = { version = "0.13", default-features = false } zeroize = { version = "1" } uniffi = { version = "=0.29.4" } uniffi_build = { version = "=0.29.4" } diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml @@ -28,10 +28,12 @@ crates = [ "radroots-nostr-ndb", "radroots-nostr-runtime", "radroots-runtime", + "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-sql-core", "radroots-sql-wasm-core", "radroots-sql-wasm-bridge", + "radroots-test-fixtures", "radroots-replica-sync", "radroots-replica-db", "radroots-replica-sync-wasm", diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -8,6 +8,7 @@ crates = [ "radroots-events", "radroots-log", "radroots-runtime", + "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-identity", "radroots-trade", @@ -34,7 +35,7 @@ crates = [ ] [internal] -crates = ["xtask"] +crates = ["xtask", "radroots-test-fixtures"] [publish_order] crates = [ @@ -43,6 +44,7 @@ crates = [ "radroots-log", "radroots-events", "radroots-runtime", + "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-events-codec", "radroots-identity", diff --git a/crates/simplex-chat-proto/Cargo.toml b/crates/simplex-chat-proto/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "radroots-simplex-chat-proto" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "simplex chat protocol primitives for the radroots sdk" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-simplex-chat-proto" +readme.workspace = true + +[features] +default = ["std"] +std = ["serde/std", "serde_json/std", "dep:zstd"] + +[dependencies] +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +serde = { workspace = true, default-features = false, features = ["alloc", "derive"] } +serde_json = { workspace = true, default-features = false, features = ["alloc"] } +zstd = { workspace = true, optional = true } diff --git a/crates/simplex-chat-proto/src/codec.rs b/crates/simplex-chat-proto/src/codec.rs @@ -0,0 +1,924 @@ +use crate::error::RadrootsSimplexChatProtoError; +use crate::model::{ + RadrootsSimplexChatBase64Url, RadrootsSimplexChatContactEvent, + RadrootsSimplexChatContainerKind, RadrootsSimplexChatContent, RadrootsSimplexChatDeleteEvent, + RadrootsSimplexChatEvent, RadrootsSimplexChatFileAcceptEvent, + RadrootsSimplexChatFileAcceptInvitationEvent, RadrootsSimplexChatFileCancelEvent, + RadrootsSimplexChatFileDescription, RadrootsSimplexChatFileDescriptionEvent, + RadrootsSimplexChatFileInvitation, RadrootsSimplexChatForwardMarker, + RadrootsSimplexChatInfoEvent, RadrootsSimplexChatMention, RadrootsSimplexChatMessage, + RadrootsSimplexChatMessageContainer, RadrootsSimplexChatMessageContentReference, + RadrootsSimplexChatMessageRef, RadrootsSimplexChatMsgNewEvent, + RadrootsSimplexChatMsgUpdateEvent, RadrootsSimplexChatNoParamsEvent, RadrootsSimplexChatObject, + RadrootsSimplexChatProbeCheckEvent, RadrootsSimplexChatProbeEvent, RadrootsSimplexChatProfile, + RadrootsSimplexChatQuotedMessage, RadrootsSimplexChatScope, +}; +use crate::version::RadrootsSimplexChatVersionRange; +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const RADROOTS_SIMPLEX_CHAT_MAX_PASSTHROUGH_LENGTH: usize = 180; +pub const RADROOTS_SIMPLEX_CHAT_COMPRESSION_LEVEL: i32 = 3; +pub const RADROOTS_SIMPLEX_CHAT_MAX_COMPRESSED_LENGTH: usize = 13_380; +pub const RADROOTS_SIMPLEX_CHAT_MAX_DECOMPRESSED_LENGTH: usize = 65_536; + +const COMPRESSED_ENVELOPE_PREFIX: u8 = b'X'; +const PASSTHROUGH_TAG: u8 = b'0'; +const COMPRESSED_TAG: u8 = b'1'; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct WireMessage { + #[serde(rename = "v", skip_serializing_if = "Option::is_none")] + version: Option<RadrootsSimplexChatVersionRange>, + #[serde(rename = "msgId", skip_serializing_if = "Option::is_none")] + msg_id: Option<RadrootsSimplexChatBase64Url>, + event: String, + params: RadrootsSimplexChatObject, +} + +pub fn decode_messages( + input: &[u8], +) -> Result<Vec<RadrootsSimplexChatMessage>, RadrootsSimplexChatProtoError> { + let Some(first) = input.first() else { + return Err(RadrootsSimplexChatProtoError::EmptyInput); + }; + + match *first { + COMPRESSED_ENVELOPE_PREFIX => decode_compressed_messages(&input[1..]), + b'{' => { + let wire = serde_json::from_slice::<WireMessage>(input).map_err( + |source: serde_json::Error| { + RadrootsSimplexChatProtoError::InvalidJson(source.to_string()) + }, + )?; + Ok(vec![decode_wire_message(wire)?]) + } + b'[' => { + let wires = serde_json::from_slice::<Vec<WireMessage>>(input).map_err( + |source: serde_json::Error| { + RadrootsSimplexChatProtoError::InvalidJson(source.to_string()) + }, + )?; + wires.into_iter().map(decode_wire_message).collect() + } + _ => Err(RadrootsSimplexChatProtoError::UnsupportedBinaryMessage), + } +} + +pub fn encode_message( + message: &RadrootsSimplexChatMessage, +) -> Result<Vec<u8>, RadrootsSimplexChatProtoError> { + let wire = encode_wire_message(message)?; + serde_json::to_vec(&wire) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())) +} + +pub fn encode_batch( + messages: &[RadrootsSimplexChatMessage], +) -> Result<Vec<u8>, RadrootsSimplexChatProtoError> { + if messages.len() == 1 { + return encode_message(&messages[0]); + } + + let wires = messages + .iter() + .map(encode_wire_message) + .collect::<Result<Vec<_>, _>>()?; + serde_json::to_vec(&wires) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())) +} + +pub fn encode_compressed_batch( + messages: &[RadrootsSimplexChatMessage], +) -> Result<Vec<u8>, RadrootsSimplexChatProtoError> { + let body = encode_batch(messages)?; + let mut encoded = Vec::new(); + encoded.push(COMPRESSED_ENVELOPE_PREFIX); + encoded.push(1); + if body.len() <= RADROOTS_SIMPLEX_CHAT_MAX_PASSTHROUGH_LENGTH { + encoded.push(PASSTHROUGH_TAG); + encoded.push(u8::try_from(body.len()).map_err(|_| { + RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "passthrough payload exceeds one-byte length".to_string(), + ) + })?); + encoded.extend_from_slice(&body); + } else { + #[cfg(feature = "std")] + { + let compressed = zstd::bulk::compress(&body, RADROOTS_SIMPLEX_CHAT_COMPRESSION_LEVEL) + .map_err(|source| { + RadrootsSimplexChatProtoError::InvalidCompressedEnvelope(source.to_string()) + })?; + encoded.push(COMPRESSED_TAG); + let length = u16::try_from(compressed.len()).map_err(|_| { + RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "compressed payload exceeds two-byte length".to_string(), + ) + })?; + encoded.extend_from_slice(&length.to_be_bytes()); + encoded.extend_from_slice(&compressed); + } + #[cfg(not(feature = "std"))] + { + let _ = body; + return Err(RadrootsSimplexChatProtoError::CompressionUnavailable); + } + } + + if encoded.len().saturating_sub(1) > RADROOTS_SIMPLEX_CHAT_MAX_COMPRESSED_LENGTH { + return Err(RadrootsSimplexChatProtoError::CompressedMessageTooLarge( + encoded.len() - 1, + )); + } + + Ok(encoded) +} + +fn decode_compressed_messages( + input: &[u8], +) -> Result<Vec<RadrootsSimplexChatMessage>, RadrootsSimplexChatProtoError> { + let mut cursor = 0; + let Some(&count) = input.get(cursor) else { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "missing compressed chunk count".to_string(), + )); + }; + cursor += 1; + if count == 0 { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "compressed envelope must contain at least one chunk".to_string(), + )); + } + + let mut messages = Vec::new(); + for _ in 0..count { + let Some(&tag) = input.get(cursor) else { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "missing compressed chunk tag".to_string(), + )); + }; + cursor += 1; + let payload = match tag { + PASSTHROUGH_TAG => { + let Some(&length) = input.get(cursor) else { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "missing passthrough length".to_string(), + )); + }; + cursor += 1; + read_exact(input, &mut cursor, usize::from(length))?.to_vec() + } + COMPRESSED_TAG => { + let length_bytes = read_exact(input, &mut cursor, 2)?; + let length = usize::from(u16::from_be_bytes([length_bytes[0], length_bytes[1]])); + let compressed = read_exact(input, &mut cursor, length)?; + #[cfg(feature = "std")] + { + decompress_compressed_chunk(compressed)? + } + #[cfg(not(feature = "std"))] + { + let _ = compressed; + return Err(RadrootsSimplexChatProtoError::CompressionUnavailable); + } + } + _ => { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + alloc::format!("unknown compressed chunk tag `{}`", char::from(tag)), + )); + } + }; + messages.extend(decode_messages(&payload)?); + } + + if cursor != input.len() { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "trailing bytes after compressed envelope".to_string(), + )); + } + + Ok(messages) +} + +#[cfg(feature = "std")] +fn decompress_compressed_chunk( + compressed: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexChatProtoError> { + let declared_size = zstd::zstd_safe::get_frame_content_size(compressed) + .map_err(|_| { + RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "compressed size not specified or corrupted".to_string(), + ) + })? + .ok_or_else(|| { + RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "compressed size not specified or exceeds limit".to_string(), + ) + })?; + if declared_size > RADROOTS_SIMPLEX_CHAT_MAX_DECOMPRESSED_LENGTH as u64 { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "compressed size not specified or exceeds limit".to_string(), + )); + } + + zstd::bulk::decompress(compressed, RADROOTS_SIMPLEX_CHAT_MAX_DECOMPRESSED_LENGTH).map_err( + |source| RadrootsSimplexChatProtoError::InvalidCompressedEnvelope(source.to_string()), + ) +} + +fn read_exact<'a>( + input: &'a [u8], + cursor: &mut usize, + length: usize, +) -> Result<&'a [u8], RadrootsSimplexChatProtoError> { + let end = cursor.saturating_add(length); + if end > input.len() { + return Err(RadrootsSimplexChatProtoError::InvalidCompressedEnvelope( + "unexpected end of compressed envelope".to_string(), + )); + } + let slice = &input[*cursor..end]; + *cursor = end; + Ok(slice) +} + +fn decode_wire_message( + wire: WireMessage, +) -> Result<RadrootsSimplexChatMessage, RadrootsSimplexChatProtoError> { + let event = match wire.event.as_str() { + "x.contact" => RadrootsSimplexChatEvent::Contact(decode_contact_event(wire.params)?), + "x.info" => RadrootsSimplexChatEvent::Info(decode_info_event(wire.params)?), + "x.info.probe" => RadrootsSimplexChatEvent::InfoProbe(decode_probe_event(wire.params)?), + "x.info.probe.check" => { + RadrootsSimplexChatEvent::InfoProbeCheck(decode_probe_check_event(wire.params)?) + } + "x.info.probe.ok" => { + RadrootsSimplexChatEvent::InfoProbeOk(decode_probe_event(wire.params)?) + } + "x.msg.new" => RadrootsSimplexChatEvent::MsgNew(RadrootsSimplexChatMsgNewEvent { + container: decode_message_container(wire.params)?, + }), + "x.msg.file.descr" => { + RadrootsSimplexChatEvent::MsgFileDescr(decode_file_description_event(wire.params)?) + } + "x.msg.update" => RadrootsSimplexChatEvent::MsgUpdate(decode_update_event(wire.params)?), + "x.msg.del" => RadrootsSimplexChatEvent::MsgDel(decode_delete_event(wire.params)?), + "x.file.acpt" => RadrootsSimplexChatEvent::FileAcpt(decode_file_accept_event(wire.params)?), + "x.file.acpt.inv" => { + RadrootsSimplexChatEvent::FileAcptInv(decode_file_accept_inv_event(wire.params)?) + } + "x.file.cancel" => { + RadrootsSimplexChatEvent::FileCancel(decode_file_cancel_event(wire.params)?) + } + "x.direct.del" => RadrootsSimplexChatEvent::DirectDel(RadrootsSimplexChatNoParamsEvent { + extra: wire.params, + }), + "x.ok" => { + RadrootsSimplexChatEvent::Ok(RadrootsSimplexChatNoParamsEvent { extra: wire.params }) + } + _ => RadrootsSimplexChatEvent::Unknown { + event: wire.event, + params: wire.params, + }, + }; + + Ok(RadrootsSimplexChatMessage { + version: wire.version, + msg_id: wire.msg_id, + event, + }) +} + +fn encode_wire_message( + message: &RadrootsSimplexChatMessage, +) -> Result<WireMessage, RadrootsSimplexChatProtoError> { + let (event, params) = match &message.event { + RadrootsSimplexChatEvent::Contact(event) => { + (String::from("x.contact"), encode_contact_event(event)?) + } + RadrootsSimplexChatEvent::Info(event) => { + (String::from("x.info"), encode_info_event(event)?) + } + RadrootsSimplexChatEvent::InfoProbe(event) => ( + String::from("x.info.probe"), + encode_probe_event("probe", &event.probe, &event.extra), + ), + RadrootsSimplexChatEvent::InfoProbeCheck(event) => ( + String::from("x.info.probe.check"), + encode_probe_event("probeHash", &event.probe_hash, &event.extra), + ), + RadrootsSimplexChatEvent::InfoProbeOk(event) => ( + String::from("x.info.probe.ok"), + encode_probe_event("probe", &event.probe, &event.extra), + ), + RadrootsSimplexChatEvent::MsgNew(event) => ( + String::from("x.msg.new"), + encode_message_container(&event.container)?, + ), + RadrootsSimplexChatEvent::MsgFileDescr(event) => ( + String::from("x.msg.file.descr"), + encode_file_description_event(event)?, + ), + RadrootsSimplexChatEvent::MsgUpdate(event) => { + (String::from("x.msg.update"), encode_update_event(event)?) + } + RadrootsSimplexChatEvent::MsgDel(event) => { + (String::from("x.msg.del"), encode_delete_event(event)) + } + RadrootsSimplexChatEvent::FileAcpt(event) => { + (String::from("x.file.acpt"), encode_file_accept_event(event)) + } + RadrootsSimplexChatEvent::FileAcptInv(event) => ( + String::from("x.file.acpt.inv"), + encode_file_accept_inv_event(event), + ), + RadrootsSimplexChatEvent::FileCancel(event) => ( + String::from("x.file.cancel"), + encode_file_cancel_event(event), + ), + RadrootsSimplexChatEvent::DirectDel(event) => { + (String::from("x.direct.del"), event.extra.clone()) + } + RadrootsSimplexChatEvent::Ok(event) => (String::from("x.ok"), event.extra.clone()), + RadrootsSimplexChatEvent::Unknown { event, params } => (event.clone(), params.clone()), + }; + + Ok(WireMessage { + version: message.version, + msg_id: message.msg_id.clone(), + event, + params, + }) +} + +fn decode_contact_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatContactEvent, RadrootsSimplexChatProtoError> { + let profile = parse_from_map::<RadrootsSimplexChatProfile>(&mut params, "profile")?; + let contact_req_id = take_optional_base64url(&mut params, "contactReqId")?; + let welcome_msg_id = take_optional_base64url(&mut params, "welcomeMsgId")?; + let req_msg_id = take_optional_base64url(&mut params, "msgId")?; + let req_content = take_optional_content(&mut params, "content")?; + let request_message = match (req_msg_id, req_content) { + (Some(msg_id), Some(content)) => { + Some(RadrootsSimplexChatMessageContentReference { msg_id, content }) + } + (Some(msg_id), None) => { + params.insert( + String::from("msgId"), + Value::String(msg_id.as_str().to_string()), + ); + None + } + (None, Some(content)) => { + params.insert( + String::from("content"), + serde_json::to_value(content).map_err(|source| { + RadrootsSimplexChatProtoError::InvalidJson(source.to_string()) + })?, + ); + None + } + (None, None) => None, + }; + + Ok(RadrootsSimplexChatContactEvent { + profile, + contact_req_id, + welcome_msg_id, + request_message, + extra: params, + }) +} + +fn encode_contact_event( + event: &RadrootsSimplexChatContactEvent, +) -> Result<RadrootsSimplexChatObject, RadrootsSimplexChatProtoError> { + let mut params = event.extra.clone(); + params.insert(String::from("profile"), to_value(&event.profile)?); + insert_optional_base64url(&mut params, "contactReqId", event.contact_req_id.as_ref()); + insert_optional_base64url(&mut params, "welcomeMsgId", event.welcome_msg_id.as_ref()); + if let Some(request_message) = &event.request_message { + insert_optional_base64url(&mut params, "msgId", Some(&request_message.msg_id)); + params.insert(String::from("content"), to_value(&request_message.content)?); + } else { + params.remove("msgId"); + params.remove("content"); + } + Ok(params) +} + +fn decode_info_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatInfoEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatInfoEvent { + profile: parse_from_map::<RadrootsSimplexChatProfile>(&mut params, "profile")?, + extra: params, + }) +} + +fn encode_info_event( + event: &RadrootsSimplexChatInfoEvent, +) -> Result<RadrootsSimplexChatObject, RadrootsSimplexChatProtoError> { + let mut params = event.extra.clone(); + params.insert(String::from("profile"), to_value(&event.profile)?); + Ok(params) +} + +fn decode_probe_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatProbeEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatProbeEvent { + probe: take_required_base64url(&mut params, "probe")?, + extra: params, + }) +} + +fn decode_probe_check_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatProbeCheckEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatProbeCheckEvent { + probe_hash: take_required_base64url(&mut params, "probeHash")?, + extra: params, + }) +} + +fn encode_probe_event( + field: &str, + value: &RadrootsSimplexChatBase64Url, + extra: &RadrootsSimplexChatObject, +) -> RadrootsSimplexChatObject { + let mut params = extra.clone(); + params.insert( + String::from(field), + Value::String(value.as_str().to_string()), + ); + params +} + +fn decode_file_description_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatFileDescriptionEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatFileDescriptionEvent { + msg_id: take_required_base64url(&mut params, "msgId")?, + file_descr: parse_from_map::<RadrootsSimplexChatFileDescription>(&mut params, "fileDescr")?, + extra: params, + }) +} + +fn encode_file_description_event( + event: &RadrootsSimplexChatFileDescriptionEvent, +) -> Result<RadrootsSimplexChatObject, RadrootsSimplexChatProtoError> { + let mut params = event.extra.clone(); + params.insert( + String::from("msgId"), + Value::String(event.msg_id.as_str().to_string()), + ); + params.insert(String::from("fileDescr"), to_value(&event.file_descr)?); + Ok(params) +} + +fn decode_update_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatMsgUpdateEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatMsgUpdateEvent { + msg_id: take_required_base64url(&mut params, "msgId")?, + content: take_required_content(&mut params, "content")?, + mentions: take_optional_mentions(&mut params)?, + ttl: take_optional_i64(&mut params, "ttl")?, + live: take_optional_bool(&mut params, "live")?, + scope: take_optional_scope(&mut params)?, + extra: params, + }) +} + +fn encode_update_event( + event: &RadrootsSimplexChatMsgUpdateEvent, +) -> Result<RadrootsSimplexChatObject, RadrootsSimplexChatProtoError> { + let mut params = event.extra.clone(); + params.insert( + String::from("msgId"), + Value::String(event.msg_id.as_str().to_string()), + ); + params.insert(String::from("content"), to_value(&event.content)?); + insert_optional_mentions(&mut params, &event.mentions)?; + insert_optional_i64(&mut params, "ttl", event.ttl); + insert_optional_bool(&mut params, "live", event.live); + insert_optional_scope(&mut params, "scope", event.scope.as_ref())?; + Ok(params) +} + +fn decode_delete_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatDeleteEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatDeleteEvent { + msg_id: take_required_base64url(&mut params, "msgId")?, + member_id: take_optional_base64url(&mut params, "memberId")?, + scope: take_optional_scope(&mut params)?, + extra: params, + }) +} + +fn encode_delete_event(event: &RadrootsSimplexChatDeleteEvent) -> RadrootsSimplexChatObject { + let mut params = event.extra.clone(); + params.insert( + String::from("msgId"), + Value::String(event.msg_id.as_str().to_string()), + ); + insert_optional_base64url(&mut params, "memberId", event.member_id.as_ref()); + if let Some(scope) = &event.scope { + params.insert( + String::from("scope"), + serde_json::to_value(scope).unwrap_or(Value::Null), + ); + } else { + params.remove("scope"); + } + params +} + +fn decode_file_accept_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatFileAcceptEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatFileAcceptEvent { + file_name: take_required_string(&mut params, "fileName")?, + extra: params, + }) +} + +fn encode_file_accept_event( + event: &RadrootsSimplexChatFileAcceptEvent, +) -> RadrootsSimplexChatObject { + let mut params = event.extra.clone(); + params.insert( + String::from("fileName"), + Value::String(event.file_name.clone()), + ); + params +} + +fn decode_file_accept_inv_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatFileAcceptInvitationEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatFileAcceptInvitationEvent { + msg_id: take_required_base64url(&mut params, "msgId")?, + file_conn_req: take_optional_string(&mut params, "fileConnReq")?, + file_name: take_required_string(&mut params, "fileName")?, + extra: params, + }) +} + +fn encode_file_accept_inv_event( + event: &RadrootsSimplexChatFileAcceptInvitationEvent, +) -> RadrootsSimplexChatObject { + let mut params = event.extra.clone(); + params.insert( + String::from("msgId"), + Value::String(event.msg_id.as_str().to_string()), + ); + insert_optional_string(&mut params, "fileConnReq", event.file_conn_req.as_ref()); + params.insert( + String::from("fileName"), + Value::String(event.file_name.clone()), + ); + params +} + +fn decode_file_cancel_event( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatFileCancelEvent, RadrootsSimplexChatProtoError> { + Ok(RadrootsSimplexChatFileCancelEvent { + msg_id: take_required_base64url(&mut params, "msgId")?, + extra: params, + }) +} + +fn encode_file_cancel_event( + event: &RadrootsSimplexChatFileCancelEvent, +) -> RadrootsSimplexChatObject { + let mut params = event.extra.clone(); + params.insert( + String::from("msgId"), + Value::String(event.msg_id.as_str().to_string()), + ); + params +} + +fn decode_message_container( + mut params: RadrootsSimplexChatObject, +) -> Result<RadrootsSimplexChatMessageContainer, RadrootsSimplexChatProtoError> { + let kind = if let Some(value) = params.remove("quote") { + RadrootsSimplexChatContainerKind::Quote( + serde_json::from_value::<RadrootsSimplexChatQuotedMessage>(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string()))?, + ) + } else if let Some(value) = params.remove("parent") { + RadrootsSimplexChatContainerKind::Comment( + serde_json::from_value::<RadrootsSimplexChatMessageRef>(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string()))?, + ) + } else if let Some(value) = params.remove("forward") { + match value { + Value::Bool(false) => RadrootsSimplexChatContainerKind::Simple, + Value::Bool(true) => { + RadrootsSimplexChatContainerKind::Forward(RadrootsSimplexChatForwardMarker::Flag) + } + Value::Object(object) => RadrootsSimplexChatContainerKind::Forward( + RadrootsSimplexChatForwardMarker::Object(object), + ), + _ => return Err(RadrootsSimplexChatProtoError::InvalidField("forward")), + } + } else { + RadrootsSimplexChatContainerKind::Simple + }; + + Ok(RadrootsSimplexChatMessageContainer { + kind, + content: take_required_content(&mut params, "content")?, + mentions: take_optional_mentions(&mut params)?, + file: take_optional_from_map::<RadrootsSimplexChatFileInvitation>(&mut params, "file")?, + ttl: take_optional_i64(&mut params, "ttl")?, + live: take_optional_bool(&mut params, "live")?, + scope: take_optional_scope(&mut params)?, + extra: params, + }) +} + +fn encode_message_container( + container: &RadrootsSimplexChatMessageContainer, +) -> Result<RadrootsSimplexChatObject, RadrootsSimplexChatProtoError> { + let mut params = container.extra.clone(); + params.insert(String::from("content"), to_value(&container.content)?); + insert_optional_mentions(&mut params, &container.mentions)?; + insert_optional_to_map(&mut params, "file", container.file.as_ref())?; + insert_optional_i64(&mut params, "ttl", container.ttl); + insert_optional_bool(&mut params, "live", container.live); + insert_optional_scope(&mut params, "scope", container.scope.as_ref())?; + + match &container.kind { + RadrootsSimplexChatContainerKind::Simple => { + params.remove("quote"); + params.remove("parent"); + params.remove("forward"); + } + RadrootsSimplexChatContainerKind::Quote(quoted) => { + params.insert(String::from("quote"), to_value(quoted)?); + params.remove("parent"); + params.remove("forward"); + } + RadrootsSimplexChatContainerKind::Comment(reference) => { + params.insert(String::from("parent"), to_value(reference)?); + params.remove("quote"); + params.remove("forward"); + } + RadrootsSimplexChatContainerKind::Forward(RadrootsSimplexChatForwardMarker::Flag) => { + params.insert(String::from("forward"), Value::Bool(true)); + params.remove("quote"); + params.remove("parent"); + } + RadrootsSimplexChatContainerKind::Forward(RadrootsSimplexChatForwardMarker::Object( + object, + )) => { + params.insert(String::from("forward"), Value::Object(object.clone())); + params.remove("quote"); + params.remove("parent"); + } + } + + Ok(params) +} + +fn parse_from_map<T>( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<T, RadrootsSimplexChatProtoError> +where + T: serde::de::DeserializeOwned, +{ + let value = params + .remove(field) + .ok_or(RadrootsSimplexChatProtoError::MissingField(field))?; + serde_json::from_value(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())) +} + +fn take_required_string( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<String, RadrootsSimplexChatProtoError> { + match params.remove(field) { + Some(Value::String(value)) => Ok(value), + Some(_) => Err(RadrootsSimplexChatProtoError::InvalidField(field)), + None => Err(RadrootsSimplexChatProtoError::MissingField(field)), + } +} + +fn take_optional_string( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<String>, RadrootsSimplexChatProtoError> { + match params.remove(field) { + Some(Value::String(value)) => Ok(Some(value)), + Some(_) => Err(RadrootsSimplexChatProtoError::InvalidField(field)), + None => Ok(None), + } +} + +fn take_required_base64url( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<RadrootsSimplexChatBase64Url, RadrootsSimplexChatProtoError> { + let value = take_required_string(params, field)?; + RadrootsSimplexChatBase64Url::parse_field(value, field) +} + +fn take_optional_base64url( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<RadrootsSimplexChatBase64Url>, RadrootsSimplexChatProtoError> { + let value = take_optional_string(params, field)?; + value + .map(|value| RadrootsSimplexChatBase64Url::parse_field(value, field)) + .transpose() +} + +fn take_optional_i64( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<i64>, RadrootsSimplexChatProtoError> { + match params.remove(field) { + Some(Value::Number(value)) => value + .as_i64() + .map(Some) + .ok_or(RadrootsSimplexChatProtoError::InvalidField(field)), + Some(_) => Err(RadrootsSimplexChatProtoError::InvalidField(field)), + None => Ok(None), + } +} + +fn take_optional_bool( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<bool>, RadrootsSimplexChatProtoError> { + match params.remove(field) { + Some(Value::Bool(value)) => Ok(Some(value)), + Some(_) => Err(RadrootsSimplexChatProtoError::InvalidField(field)), + None => Ok(None), + } +} + +fn take_optional_scope( + params: &mut RadrootsSimplexChatObject, +) -> Result<Option<RadrootsSimplexChatScope>, RadrootsSimplexChatProtoError> { + match params.remove("scope") { + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())), + None => Ok(None), + } +} + +fn take_optional_mentions( + params: &mut RadrootsSimplexChatObject, +) -> Result<BTreeMap<String, RadrootsSimplexChatMention>, RadrootsSimplexChatProtoError> { + match params.remove("mentions") { + Some(value) => serde_json::from_value(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())), + None => Ok(BTreeMap::new()), + } +} + +fn take_required_content( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<RadrootsSimplexChatContent, RadrootsSimplexChatProtoError> { + let value = params + .remove(field) + .ok_or(RadrootsSimplexChatProtoError::MissingField(field))?; + serde_json::from_value(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())) +} + +fn take_optional_content( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<RadrootsSimplexChatContent>, RadrootsSimplexChatProtoError> { + match params.remove(field) { + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())), + None => Ok(None), + } +} + +fn take_optional_from_map<T>( + params: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Option<T>, RadrootsSimplexChatProtoError> +where + T: serde::de::DeserializeOwned, +{ + match params.remove(field) { + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())), + None => Ok(None), + } +} + +fn insert_optional_base64url( + params: &mut RadrootsSimplexChatObject, + field: &str, + value: Option<&RadrootsSimplexChatBase64Url>, +) { + if let Some(value) = value { + params.insert( + String::from(field), + Value::String(value.as_str().to_string()), + ); + } else { + params.remove(field); + } +} + +fn insert_optional_string( + params: &mut RadrootsSimplexChatObject, + field: &str, + value: Option<&String>, +) { + if let Some(value) = value { + params.insert(String::from(field), Value::String(value.clone())); + } else { + params.remove(field); + } +} + +fn insert_optional_i64(params: &mut RadrootsSimplexChatObject, field: &str, value: Option<i64>) { + if let Some(value) = value { + params.insert(String::from(field), Value::from(value)); + } else { + params.remove(field); + } +} + +fn insert_optional_bool(params: &mut RadrootsSimplexChatObject, field: &str, value: Option<bool>) { + if let Some(value) = value { + params.insert(String::from(field), Value::Bool(value)); + } else { + params.remove(field); + } +} + +fn insert_optional_scope( + params: &mut RadrootsSimplexChatObject, + field: &str, + value: Option<&RadrootsSimplexChatScope>, +) -> Result<(), RadrootsSimplexChatProtoError> { + if let Some(value) = value { + params.insert(String::from(field), to_value(value)?); + } else { + params.remove(field); + } + Ok(()) +} + +fn insert_optional_mentions( + params: &mut RadrootsSimplexChatObject, + mentions: &BTreeMap<String, RadrootsSimplexChatMention>, +) -> Result<(), RadrootsSimplexChatProtoError> { + if mentions.is_empty() { + params.remove("mentions"); + } else { + params.insert(String::from("mentions"), to_value(mentions)?); + } + Ok(()) +} + +fn insert_optional_to_map<T>( + params: &mut RadrootsSimplexChatObject, + field: &str, + value: Option<&T>, +) -> Result<(), RadrootsSimplexChatProtoError> +where + T: Serialize, +{ + if let Some(value) = value { + params.insert(String::from(field), to_value(value)?); + } else { + params.remove(field); + } + Ok(()) +} + +fn to_value<T>(value: &T) -> Result<Value, RadrootsSimplexChatProtoError> +where + T: Serialize, +{ + serde_json::to_value(value) + .map_err(|source| RadrootsSimplexChatProtoError::InvalidJson(source.to_string())) +} diff --git a/crates/simplex-chat-proto/src/error.rs b/crates/simplex-chat-proto/src/error.rs @@ -0,0 +1,57 @@ +#[cfg(not(feature = "std"))] +use alloc::string::String; +use core::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexChatProtoError { + EmptyInput, + InvalidUtf8, + InvalidJson(String), + InvalidVersionRange(String), + InvalidBase64Url { field: &'static str, value: String }, + MissingField(&'static str), + InvalidField(&'static str), + InvalidCompressedEnvelope(String), + CompressedMessageTooLarge(usize), + CompressionUnavailable, + UnsupportedBinaryMessage, +} + +impl fmt::Display for RadrootsSimplexChatProtoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyInput => write!(f, "empty SimpleX chat input"), + Self::InvalidUtf8 => write!(f, "invalid UTF-8 in SimpleX chat input"), + Self::InvalidJson(error) => write!(f, "invalid SimpleX chat JSON: {error}"), + Self::InvalidVersionRange(range) => { + write!(f, "invalid SimpleX chat version range `{range}`") + } + Self::InvalidBase64Url { field, value } => { + write!(f, "invalid base64url value for `{field}`: `{value}`") + } + Self::MissingField(field) => write!(f, "missing required SimpleX chat field `{field}`"), + Self::InvalidField(field) => write!(f, "invalid SimpleX chat field `{field}`"), + Self::InvalidCompressedEnvelope(error) => { + write!(f, "invalid compressed SimpleX chat envelope: {error}") + } + Self::CompressedMessageTooLarge(length) => { + write!(f, "compressed SimpleX chat message exceeds limit: {length}") + } + Self::CompressionUnavailable => { + write!( + f, + "SimpleX chat compression support requires the `std` feature" + ) + } + Self::UnsupportedBinaryMessage => { + write!( + f, + "binary SimpleX chat messages are not supported by this crate" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSimplexChatProtoError {} diff --git a/crates/simplex-chat-proto/src/lib.rs b/crates/simplex-chat-proto/src/lib.rs @@ -0,0 +1,39 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod codec; +pub mod error; +pub mod model; +pub mod version; + +pub mod prelude { + pub use crate::codec::{ + RADROOTS_SIMPLEX_CHAT_COMPRESSION_LEVEL, RADROOTS_SIMPLEX_CHAT_MAX_COMPRESSED_LENGTH, + RADROOTS_SIMPLEX_CHAT_MAX_DECOMPRESSED_LENGTH, + RADROOTS_SIMPLEX_CHAT_MAX_PASSTHROUGH_LENGTH, decode_messages, encode_batch, + encode_compressed_batch, encode_message, + }; + pub use crate::error::RadrootsSimplexChatProtoError; + pub use crate::model::{ + RadrootsSimplexChatBase64Url, RadrootsSimplexChatContactEvent, + RadrootsSimplexChatContainerKind, RadrootsSimplexChatContent, + RadrootsSimplexChatDeleteEvent, RadrootsSimplexChatEvent, + RadrootsSimplexChatFileAcceptEvent, RadrootsSimplexChatFileAcceptInvitationEvent, + RadrootsSimplexChatFileCancelEvent, RadrootsSimplexChatFileDescription, + RadrootsSimplexChatFileDescriptionEvent, RadrootsSimplexChatFileInvitation, + RadrootsSimplexChatForwardMarker, RadrootsSimplexChatInfoEvent, + RadrootsSimplexChatLinkContent, RadrootsSimplexChatLinkPreview, RadrootsSimplexChatMention, + RadrootsSimplexChatMessage, RadrootsSimplexChatMessageContainer, + RadrootsSimplexChatMessageContentReference, RadrootsSimplexChatMessageRef, + RadrootsSimplexChatMsgNewEvent, RadrootsSimplexChatMsgUpdateEvent, + RadrootsSimplexChatNoParamsEvent, RadrootsSimplexChatObject, RadrootsSimplexChatPeerType, + RadrootsSimplexChatProbeCheckEvent, RadrootsSimplexChatProbeEvent, + RadrootsSimplexChatProfile, RadrootsSimplexChatQuotedMessage, RadrootsSimplexChatScope, + }; + pub use crate::version::{ + RADROOTS_SIMPLEX_CHAT_COMPRESSION_VERSION, RADROOTS_SIMPLEX_CHAT_CURRENT_VERSION, + RADROOTS_SIMPLEX_CHAT_INITIAL_VERSION, RadrootsSimplexChatVersionRange, + }; +} diff --git a/crates/simplex-chat-proto/src/model.rs b/crates/simplex-chat-proto/src/model.rs @@ -0,0 +1,701 @@ +use crate::error::RadrootsSimplexChatProtoError; +use crate::version::RadrootsSimplexChatVersionRange; +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value}; + +pub type RadrootsSimplexChatObject = Map<String, Value>; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsSimplexChatBase64Url(String); + +impl RadrootsSimplexChatBase64Url { + pub fn new(value: impl Into<String>) -> Result<Self, RadrootsSimplexChatProtoError> { + let value = value.into(); + URL_SAFE_NO_PAD.decode(value.as_bytes()).map_err(|_| { + RadrootsSimplexChatProtoError::InvalidBase64Url { + field: "base64url", + value: value.clone(), + } + })?; + Ok(Self(value)) + } + + pub fn parse_field( + value: String, + field: &'static str, + ) -> Result<Self, RadrootsSimplexChatProtoError> { + URL_SAFE_NO_PAD.decode(value.as_bytes()).map_err(|_| { + RadrootsSimplexChatProtoError::InvalidBase64Url { + field, + value: value.clone(), + } + })?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Serialize for RadrootsSimplexChatBase64Url { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RadrootsSimplexChatBase64Url { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = <String as Deserialize>::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexChatPeerType { + Human, + Bot, + Unknown(String), +} + +impl Serialize for RadrootsSimplexChatPeerType { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(match self { + Self::Human => "human", + Self::Bot => "bot", + Self::Unknown(value) => value, + }) + } +} + +impl<'de> Deserialize<'de> for RadrootsSimplexChatPeerType { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = <String as Deserialize>::deserialize(deserializer)?; + Ok(match value.as_str() { + "human" => Self::Human, + "bot" => Self::Bot, + _ => Self::Unknown(value), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatProfile { + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "fullName")] + pub full_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option<String>, + #[serde(rename = "shortDescr", skip_serializing_if = "Option::is_none")] + pub short_descr: Option<String>, + #[serde(rename = "contactLink", skip_serializing_if = "Option::is_none")] + pub contact_link: Option<String>, + #[serde(rename = "peerType", skip_serializing_if = "Option::is_none")] + pub peer_type: Option<RadrootsSimplexChatPeerType>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferences: Option<Value>, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatMessageRef { + #[serde(rename = "msgId", skip_serializing_if = "Option::is_none")] + pub msg_id: Option<RadrootsSimplexChatBase64Url>, + #[serde(rename = "sentAt")] + pub sent_at: String, + pub sent: bool, + #[serde(rename = "memberId", skip_serializing_if = "Option::is_none")] + pub member_id: Option<RadrootsSimplexChatBase64Url>, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatMention { + #[serde(rename = "memberId")] + pub member_id: RadrootsSimplexChatBase64Url, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatLinkContent { + Page { + extra: RadrootsSimplexChatObject, + }, + Image { + extra: RadrootsSimplexChatObject, + }, + Video { + duration: Option<i64>, + extra: RadrootsSimplexChatObject, + }, + Unknown { + content_type: String, + raw: RadrootsSimplexChatObject, + }, +} + +impl Serialize for RadrootsSimplexChatLinkContent { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut object = RadrootsSimplexChatObject::new(); + match self { + Self::Page { extra } => { + object.insert(String::from("type"), Value::String(String::from("page"))); + object.extend(extra.clone()); + } + Self::Image { extra } => { + object.insert(String::from("type"), Value::String(String::from("image"))); + object.extend(extra.clone()); + } + Self::Video { duration, extra } => { + object.insert(String::from("type"), Value::String(String::from("video"))); + if let Some(duration) = duration { + object.insert(String::from("duration"), Value::from(*duration)); + } + object.extend(extra.clone()); + } + Self::Unknown { raw, .. } => { + object = raw.clone(); + } + } + object.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RadrootsSimplexChatLinkContent { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let mut raw = <RadrootsSimplexChatObject as Deserialize>::deserialize(deserializer)?; + let content_type = match raw.remove("type") { + Some(Value::String(value)) => value, + Some(_) => return Err(D::Error::custom("invalid link content type")), + None => return Err(D::Error::custom("missing link content type")), + }; + + Ok(match content_type.as_str() { + "page" => Self::Page { extra: raw }, + "image" => Self::Image { extra: raw }, + "video" => { + let duration = match raw.remove("duration") { + Some(Value::Number(value)) => value.as_i64(), + Some(_) => return Err(D::Error::custom("invalid duration")), + None => None, + }; + Self::Video { + duration, + extra: raw, + } + } + _ => { + raw.insert(String::from("type"), Value::String(content_type.clone())); + Self::Unknown { content_type, raw } + } + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatLinkPreview { + pub uri: String, + pub title: String, + pub description: String, + pub image: RadrootsSimplexChatBase64Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option<RadrootsSimplexChatLinkContent>, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatContent { + Text { + text: String, + extra: RadrootsSimplexChatObject, + }, + Link { + text: String, + preview: RadrootsSimplexChatLinkPreview, + extra: RadrootsSimplexChatObject, + }, + Image { + text: String, + image: RadrootsSimplexChatBase64Url, + extra: RadrootsSimplexChatObject, + }, + Video { + text: String, + image: RadrootsSimplexChatBase64Url, + duration: i64, + extra: RadrootsSimplexChatObject, + }, + Voice { + text: String, + duration: i64, + extra: RadrootsSimplexChatObject, + }, + File { + text: String, + extra: RadrootsSimplexChatObject, + }, + Unknown { + content_type: String, + text: Option<String>, + raw: RadrootsSimplexChatObject, + }, +} + +impl Serialize for RadrootsSimplexChatContent { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut object = RadrootsSimplexChatObject::new(); + match self { + Self::Text { text, extra } => { + object.insert(String::from("type"), Value::String(String::from("text"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.extend(extra.clone()); + } + Self::Link { + text, + preview, + extra, + } => { + object.insert(String::from("type"), Value::String(String::from("link"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.insert( + String::from("preview"), + serde_json::to_value(preview).map_err(serde::ser::Error::custom)?, + ); + object.extend(extra.clone()); + } + Self::Image { text, image, extra } => { + object.insert(String::from("type"), Value::String(String::from("image"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.insert( + String::from("image"), + Value::String(image.as_str().to_string()), + ); + object.extend(extra.clone()); + } + Self::Video { + text, + image, + duration, + extra, + } => { + object.insert(String::from("type"), Value::String(String::from("video"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.insert( + String::from("image"), + Value::String(image.as_str().to_string()), + ); + object.insert(String::from("duration"), Value::from(*duration)); + object.extend(extra.clone()); + } + Self::Voice { + text, + duration, + extra, + } => { + object.insert(String::from("type"), Value::String(String::from("voice"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.insert(String::from("duration"), Value::from(*duration)); + object.extend(extra.clone()); + } + Self::File { text, extra } => { + object.insert(String::from("type"), Value::String(String::from("file"))); + object.insert(String::from("text"), Value::String(text.clone())); + object.extend(extra.clone()); + } + Self::Unknown { raw, .. } => { + object = raw.clone(); + } + } + + object.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RadrootsSimplexChatContent { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let mut raw = <RadrootsSimplexChatObject as Deserialize>::deserialize(deserializer)?; + let content_type = match raw.remove("type") { + Some(Value::String(value)) => value, + Some(_) => return Err(D::Error::custom("invalid content type")), + None => return Err(D::Error::custom("missing content type")), + }; + + Ok(match content_type.as_str() { + "text" => Self::Text { + text: expect_string::<D>(&mut raw, "text")?, + extra: raw, + }, + "link" => Self::Link { + text: expect_string::<D>(&mut raw, "text")?, + preview: serde_json::from_value( + expect_value(&mut raw, "preview").map_err(D::Error::custom)?, + ) + .map_err(D::Error::custom)?, + extra: raw, + }, + "image" => Self::Image { + text: expect_string::<D>(&mut raw, "text")?, + image: expect_base64url::<D>(&mut raw, "image")?, + extra: raw, + }, + "video" => Self::Video { + text: expect_string::<D>(&mut raw, "text")?, + image: expect_base64url::<D>(&mut raw, "image")?, + duration: expect_i64::<D>(&mut raw, "duration")?, + extra: raw, + }, + "voice" => Self::Voice { + text: expect_string::<D>(&mut raw, "text")?, + duration: expect_i64::<D>(&mut raw, "duration")?, + extra: raw, + }, + "file" => Self::File { + text: expect_string::<D>(&mut raw, "text")?, + extra: raw, + }, + _ => { + let text = match raw.get("text") { + Some(Value::String(value)) => Some(value.clone()), + _ => None, + }; + raw.insert(String::from("type"), Value::String(content_type.clone())); + Self::Unknown { + content_type, + text, + raw, + } + } + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatFileDescription { + #[serde(rename = "fileDescrText")] + pub file_descr_text: String, + #[serde(rename = "fileDescrPartNo")] + pub file_descr_part_no: i64, + #[serde(rename = "fileDescrComplete")] + pub file_descr_complete: bool, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatFileInvitation { + #[serde(rename = "fileName")] + pub file_name: String, + #[serde(rename = "fileSize")] + pub file_size: u32, + #[serde(rename = "fileDigest", skip_serializing_if = "Option::is_none")] + pub file_digest: Option<RadrootsSimplexChatBase64Url>, + #[serde(rename = "fileConnReq", skip_serializing_if = "Option::is_none")] + pub file_conn_req: Option<String>, + #[serde(rename = "fileDescr", skip_serializing_if = "Option::is_none")] + pub file_descr: Option<RadrootsSimplexChatFileDescription>, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RadrootsSimplexChatQuotedMessage { + #[serde(rename = "msgRef")] + pub msg_ref: RadrootsSimplexChatMessageRef, + pub content: RadrootsSimplexChatContent, + #[serde(flatten, default)] + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatForwardMarker { + Flag, + Object(RadrootsSimplexChatObject), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatScope { + Member { + member_id: RadrootsSimplexChatBase64Url, + extra: RadrootsSimplexChatObject, + }, + Unknown(Value), +} + +impl Serialize for RadrootsSimplexChatScope { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + match self { + Self::Member { member_id, extra } => { + let mut data = RadrootsSimplexChatObject::new(); + data.insert( + String::from("memberId"), + Value::String(member_id.as_str().to_string()), + ); + data.extend(extra.clone()); + + let mut object = RadrootsSimplexChatObject::new(); + object.insert(String::from("type"), Value::String(String::from("member"))); + object.insert(String::from("data"), Value::Object(data)); + object.serialize(serializer) + } + Self::Unknown(value) => value.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RadrootsSimplexChatScope { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let original = value.clone(); + let Value::Object(mut object) = value else { + return Ok(Self::Unknown(original)); + }; + + let Some(Value::String(scope_type)) = object.remove("type") else { + return Ok(Self::Unknown(original)); + }; + + if scope_type != "member" { + return Ok(Self::Unknown(original)); + } + + let Some(Value::Object(mut data)) = object.remove("data") else { + return Ok(Self::Unknown(original)); + }; + + let member_id = expect_base64url::<D>(&mut data, "memberId")?; + Ok(Self::Member { + member_id, + extra: data, + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatContainerKind { + Simple, + Quote(RadrootsSimplexChatQuotedMessage), + Comment(RadrootsSimplexChatMessageRef), + Forward(RadrootsSimplexChatForwardMarker), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatMessageContainer { + pub kind: RadrootsSimplexChatContainerKind, + pub content: RadrootsSimplexChatContent, + pub mentions: BTreeMap<String, RadrootsSimplexChatMention>, + pub file: Option<RadrootsSimplexChatFileInvitation>, + pub ttl: Option<i64>, + pub live: Option<bool>, + pub scope: Option<RadrootsSimplexChatScope>, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatMessageContentReference { + pub msg_id: RadrootsSimplexChatBase64Url, + pub content: RadrootsSimplexChatContent, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatNoParamsEvent { + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatContactEvent { + pub profile: RadrootsSimplexChatProfile, + pub contact_req_id: Option<RadrootsSimplexChatBase64Url>, + pub welcome_msg_id: Option<RadrootsSimplexChatBase64Url>, + pub request_message: Option<RadrootsSimplexChatMessageContentReference>, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatInfoEvent { + pub profile: RadrootsSimplexChatProfile, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatProbeEvent { + pub probe: RadrootsSimplexChatBase64Url, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatProbeCheckEvent { + pub probe_hash: RadrootsSimplexChatBase64Url, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatMsgNewEvent { + pub container: RadrootsSimplexChatMessageContainer, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatFileDescriptionEvent { + pub msg_id: RadrootsSimplexChatBase64Url, + pub file_descr: RadrootsSimplexChatFileDescription, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatMsgUpdateEvent { + pub msg_id: RadrootsSimplexChatBase64Url, + pub content: RadrootsSimplexChatContent, + pub mentions: BTreeMap<String, RadrootsSimplexChatMention>, + pub ttl: Option<i64>, + pub live: Option<bool>, + pub scope: Option<RadrootsSimplexChatScope>, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatDeleteEvent { + pub msg_id: RadrootsSimplexChatBase64Url, + pub member_id: Option<RadrootsSimplexChatBase64Url>, + pub scope: Option<RadrootsSimplexChatScope>, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatFileAcceptEvent { + pub file_name: String, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatFileAcceptInvitationEvent { + pub msg_id: RadrootsSimplexChatBase64Url, + pub file_conn_req: Option<String>, + pub file_name: String, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatFileCancelEvent { + pub msg_id: RadrootsSimplexChatBase64Url, + pub extra: RadrootsSimplexChatObject, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadrootsSimplexChatEvent { + Contact(RadrootsSimplexChatContactEvent), + Info(RadrootsSimplexChatInfoEvent), + InfoProbe(RadrootsSimplexChatProbeEvent), + InfoProbeCheck(RadrootsSimplexChatProbeCheckEvent), + InfoProbeOk(RadrootsSimplexChatProbeEvent), + MsgNew(RadrootsSimplexChatMsgNewEvent), + MsgFileDescr(RadrootsSimplexChatFileDescriptionEvent), + MsgUpdate(RadrootsSimplexChatMsgUpdateEvent), + MsgDel(RadrootsSimplexChatDeleteEvent), + FileAcpt(RadrootsSimplexChatFileAcceptEvent), + FileAcptInv(RadrootsSimplexChatFileAcceptInvitationEvent), + FileCancel(RadrootsSimplexChatFileCancelEvent), + DirectDel(RadrootsSimplexChatNoParamsEvent), + Ok(RadrootsSimplexChatNoParamsEvent), + Unknown { + event: String, + params: RadrootsSimplexChatObject, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadrootsSimplexChatMessage { + pub version: Option<RadrootsSimplexChatVersionRange>, + pub msg_id: Option<RadrootsSimplexChatBase64Url>, + pub event: RadrootsSimplexChatEvent, +} + +pub(crate) fn expect_value( + map: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<Value, RadrootsSimplexChatProtoError> { + map.remove(field) + .ok_or(RadrootsSimplexChatProtoError::MissingField(field)) +} + +fn expect_string<'de, D>( + map: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<String, D::Error> +where + D: Deserializer<'de>, +{ + match expect_value(map, field).map_err(D::Error::custom)? { + Value::String(value) => Ok(value), + _ => Err(D::Error::custom( + RadrootsSimplexChatProtoError::InvalidField(field), + )), + } +} + +fn expect_i64<'de, D>( + map: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<i64, D::Error> +where + D: Deserializer<'de>, +{ + match expect_value(map, field).map_err(D::Error::custom)? { + Value::Number(value) => value + .as_i64() + .ok_or_else(|| D::Error::custom(RadrootsSimplexChatProtoError::InvalidField(field))), + _ => Err(D::Error::custom( + RadrootsSimplexChatProtoError::InvalidField(field), + )), + } +} + +fn expect_base64url<'de, D>( + map: &mut RadrootsSimplexChatObject, + field: &'static str, +) -> Result<RadrootsSimplexChatBase64Url, D::Error> +where + D: Deserializer<'de>, +{ + expect_string::<D>(map, field).and_then(|value| { + RadrootsSimplexChatBase64Url::parse_field(value, field).map_err(D::Error::custom) + }) +} diff --git a/crates/simplex-chat-proto/src/version.rs b/crates/simplex-chat-proto/src/version.rs @@ -0,0 +1,120 @@ +use crate::error::RadrootsSimplexChatProtoError; +use alloc::string::{String, ToString}; +use core::fmt; +use core::str::FromStr; + +pub const RADROOTS_SIMPLEX_CHAT_INITIAL_VERSION: u16 = 1; +pub const RADROOTS_SIMPLEX_CHAT_COMPRESSION_VERSION: u16 = 8; +pub const RADROOTS_SIMPLEX_CHAT_CURRENT_VERSION: u16 = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsSimplexChatVersionRange { + pub min: u16, + pub max: u16, +} + +impl RadrootsSimplexChatVersionRange { + pub const fn single(version: u16) -> Self { + Self { + min: version, + max: version, + } + } + + pub fn new(min: u16, max: u16) -> Result<Self, RadrootsSimplexChatProtoError> { + if min == 0 || max == 0 || min > max { + return Err(RadrootsSimplexChatProtoError::InvalidVersionRange( + alloc::format!("{min}-{max}"), + )); + } + + Ok(Self { min, max }) + } + + pub const fn supports_compression(&self) -> bool { + self.max >= RADROOTS_SIMPLEX_CHAT_COMPRESSION_VERSION + } +} + +impl Default for RadrootsSimplexChatVersionRange { + fn default() -> Self { + Self::single(RADROOTS_SIMPLEX_CHAT_CURRENT_VERSION) + } +} + +impl fmt::Display for RadrootsSimplexChatVersionRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.min == self.max { + write!(f, "{}", self.min) + } else { + write!(f, "{}-{}", self.min, self.max) + } + } +} + +impl FromStr for RadrootsSimplexChatVersionRange { + type Err = RadrootsSimplexChatProtoError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RadrootsSimplexChatProtoError::InvalidVersionRange( + String::new(), + )); + } + + if let Some((min, max)) = trimmed.split_once('-') { + let min = parse_version(min, trimmed)?; + let max = parse_version(max, trimmed)?; + Self::new(min, max) + } else { + let version = parse_version(trimmed, trimmed)?; + Self::new(version, version) + } + } +} + +fn parse_version(value: &str, original: &str) -> Result<u16, RadrootsSimplexChatProtoError> { + value + .parse::<u16>() + .map_err(|_| RadrootsSimplexChatProtoError::InvalidVersionRange(original.to_string())) +} + +impl serde::Serialize for RadrootsSimplexChatVersionRange { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for RadrootsSimplexChatVersionRange { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_single_version() { + let range = "8".parse::<RadrootsSimplexChatVersionRange>().unwrap(); + assert_eq!(range, RadrootsSimplexChatVersionRange::single(8)); + assert!(range.supports_compression()); + } + + #[test] + fn parses_version_range() { + let range = "1-16".parse::<RadrootsSimplexChatVersionRange>().unwrap(); + assert_eq!(range.min, 1); + assert_eq!(range.max, 16); + assert_eq!(range.to_string(), "1-16"); + } +} diff --git a/crates/simplex-chat-proto/tests/chat_proto.rs b/crates/simplex-chat-proto/tests/chat_proto.rs @@ -0,0 +1,251 @@ +use radroots_simplex_chat_proto::prelude::{ + RadrootsSimplexChatContainerKind, RadrootsSimplexChatContent, RadrootsSimplexChatEvent, + RadrootsSimplexChatForwardMarker, RadrootsSimplexChatMessage, RadrootsSimplexChatScope, + RadrootsSimplexChatVersionRange, decode_messages, encode_compressed_batch, encode_message, +}; +use serde_json::{Value, json}; + +fn decode_one(value: Value) -> RadrootsSimplexChatMessage { + let bytes = serde_json::to_vec(&value).expect("serialize synthetic test value"); + let mut messages = decode_messages(&bytes).expect("decode synthetic test value"); + assert_eq!(messages.len(), 1, "expected exactly one decoded message"); + messages.pop().expect("single decoded message") +} + +fn encode_value(message: &RadrootsSimplexChatMessage) -> Value { + serde_json::from_slice(&encode_message(message).expect("encode message")) + .expect("parse encoded message json") +} + +#[test] +fn roundtrips_ok_message_with_top_level_version() { + let expected = json!({ + "v": "1-16", + "msgId": "AQ", + "event": "x.ok", + "params": {}, + }); + + let message = decode_one(expected.clone()); + assert_eq!( + message.version, + Some(RadrootsSimplexChatVersionRange::new(1, 16).unwrap()) + ); + assert!(matches!(message.event, RadrootsSimplexChatEvent::Ok(_))); + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn roundtrips_contact_event_with_request_message_fields() { + let expected = json!({ + "v": "1-16", + "event": "x.contact", + "params": { + "profile": { + "displayName": "rr", + "fullName": "Rad Roots", + "peerType": "human" + }, + "contactReqId": "AQ", + "welcomeMsgId": "Ag", + "msgId": "Aw", + "content": { + "type": "text", + "text": "hello from rr" + }, + "nickname": "roots" + } + }); + + let message = decode_one(expected.clone()); + let RadrootsSimplexChatEvent::Contact(event) = &message.event else { + panic!("expected contact event"); + }; + assert_eq!(event.profile.display_name, "rr"); + assert!(event.request_message.is_some()); + assert_eq!( + event.extra.get("nickname"), + Some(&Value::String("roots".into())) + ); + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn roundtrips_msg_new_with_object_forward_marker() { + let expected = json!({ + "event": "x.msg.new", + "params": { + "forward": { + "groupLinkId": "AQ", + "mode": "public" + }, + "content": { + "type": "text", + "text": "forwarded text" + }, + "ttl": 60, + "live": true + } + }); + + let message = decode_one(expected.clone()); + let RadrootsSimplexChatEvent::MsgNew(event) = &message.event else { + panic!("expected x.msg.new"); + }; + assert!(matches!( + event.container.kind, + RadrootsSimplexChatContainerKind::Forward(RadrootsSimplexChatForwardMarker::Object(_)) + )); + assert_eq!(event.container.ttl, Some(60)); + assert_eq!(event.container.live, Some(true)); + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn roundtrips_msg_update_with_mentions_and_scope() { + let expected = json!({ + "event": "x.msg.update", + "params": { + "msgId": "AQ", + "content": { + "type": "text", + "text": "edited" + }, + "mentions": { + "lead": { + "memberId": "Ag", + "label": "Lead" + } + }, + "scope": { + "type": "member", + "data": { + "memberId": "Aw", + "role": "writer" + } + }, + "ttl": 90, + "live": false + } + }); + + let message = decode_one(expected.clone()); + let RadrootsSimplexChatEvent::MsgUpdate(event) = &message.event else { + panic!("expected x.msg.update"); + }; + assert_eq!(event.mentions.len(), 1); + assert!(matches!( + event.scope, + Some(RadrootsSimplexChatScope::Member { .. }) + )); + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn roundtrips_file_description_and_accept_invitation_events() { + let file_descr = json!({ + "event": "x.msg.file.descr", + "params": { + "msgId": "AQ", + "fileDescr": { + "fileDescrText": "part 1", + "fileDescrPartNo": 1, + "fileDescrComplete": true + }, + "label": "intro" + } + }); + + let file_accept_inv = json!({ + "event": "x.file.acpt.inv", + "params": { + "msgId": "Ag", + "fileConnReq": "smp://example", + "fileName": "hello.txt", + "label": "doc" + } + }); + + let descr_message = decode_one(file_descr.clone()); + let acpt_inv_message = decode_one(file_accept_inv.clone()); + assert_eq!(encode_value(&descr_message), file_descr); + assert_eq!(encode_value(&acpt_inv_message), file_accept_inv); +} + +#[test] +fn preserves_unknown_event_params() { + let expected = json!({ + "v": "8", + "event": "x.future.dm", + "params": { + "flag": true, + "nested": { + "kind": "preview" + } + } + }); + + let message = decode_one(expected.clone()); + let RadrootsSimplexChatEvent::Unknown { event, params } = &message.event else { + panic!("expected unknown event"); + }; + assert_eq!(event, "x.future.dm"); + assert_eq!(params.get("flag"), Some(&Value::Bool(true))); + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn preserves_unknown_content_types_inside_direct_messages() { + let expected = json!({ + "event": "x.msg.new", + "params": { + "content": { + "type": "chat", + "text": "join us", + "chatLink": "https://radroots.example/simplex" + } + } + }); + + let message = decode_one(expected.clone()); + let RadrootsSimplexChatEvent::MsgNew(event) = &message.event else { + panic!("expected x.msg.new"); + }; + match &event.container.content { + RadrootsSimplexChatContent::Unknown { + content_type, + text, + raw, + } => { + assert_eq!(content_type, "chat"); + assert_eq!(text.as_deref(), Some("join us")); + assert_eq!( + raw.get("chatLink"), + Some(&Value::String("https://radroots.example/simplex".into())) + ); + } + other => panic!("expected unknown content, got {other:?}"), + } + assert_eq!(encode_value(&message), expected); +} + +#[test] +fn roundtrips_official_compressed_envelope_batches() { + let message = decode_one(json!({ + "v": "1-16", + "msgId": "AQ", + "event": "x.msg.new", + "params": { + "content": { + "type": "text", + "text": "x".repeat(256) + } + } + })); + + let encoded = encode_compressed_batch(&[message.clone()]).expect("encode compressed batch"); + assert_eq!(encoded.first(), Some(&b'X')); + + let decoded = decode_messages(&encoded).expect("decode compressed envelope"); + assert_eq!(decoded, vec![message]); +}