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:
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]);
+}