messages, wavings and images

This commit is contained in:
2026-08-26 00:59:31 +08:00
parent 5d7d294a80
commit 74a5268420
22 changed files with 1779 additions and 89 deletions
+4
View File
@@ -6,3 +6,7 @@ publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
specta = { version = "2.0.0-rc.21", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
+133 -4
View File
@@ -1,6 +1,11 @@
use serde::{Deserialize, Serialize};
use specta::Type;
pub const VERSION: u8 = 1;
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
pub const MAX_IMAGE_DIMENSION: u32 = 480;
pub const MAX_TEXT_CHARS: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -30,16 +35,90 @@ pub enum ClientMessage {
payload: String,
signature: String,
},
Interaction {
interaction_id: String,
recipient_id: String,
payload: String,
signature: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InteractionDeliveryStatus {
Delivered,
Unavailable,
Busy,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InteractionContent {
Text {
text: String,
},
Wave,
Image {
#[serde(rename = "mediaType")]
#[specta(rename = "mediaType")]
media_type: String,
data: String,
},
}
impl InteractionContent {
pub fn validate(&self) -> Result<(), String> {
match self {
Self::Text { text } if text.trim().is_empty() => {
Err("Message cannot be empty".to_owned())
}
Self::Text { text } if text.chars().count() > MAX_TEXT_CHARS => {
Err(format!("Message exceeds {MAX_TEXT_CHARS} characters"))
}
Self::Image { media_type, .. }
if !matches!(media_type.as_str(), "image/webp" | "image/jpeg") =>
{
Err("Image must be WebP or JPEG".to_owned())
}
Self::Image { data, .. } if data.is_empty() || data.len() > MAX_IMAGE_B64_SIZE => {
Err(format!(
"Compressed image must be at most {} KiB",
MAX_IMAGE_B64_SIZE / 1024
))
}
_ => Ok(()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ServerMessage {
Challenge { version: u8, challenge: String },
Challenge {
version: u8,
challenge: String,
},
Registered,
FriendProfileUpdated { profile: Profile },
FriendProfiles { profiles: Vec<Profile> },
FriendLiveData { friend_id: String, payload: String },
FriendProfileUpdated {
profile: Profile,
},
FriendProfiles {
profiles: Vec<Profile>,
},
FriendLiveData {
friend_id: String,
payload: String,
},
FriendInteraction {
interaction_id: String,
friend_id: String,
payload: String,
},
InteractionDelivery {
interaction_id: String,
status: InteractionDeliveryStatus,
},
}
pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec<u8> {
@@ -56,6 +135,19 @@ pub fn message_bytes(payload: &str) -> Vec<u8> {
format!("wyd-message-v{VERSION}\n{payload}").into_bytes()
}
pub fn interaction_bytes(interaction_id: &str, recipient_id: &str, payload: &str) -> Vec<u8> {
let mut bytes = format!("wyd-interaction-v{VERSION}\0").into_bytes();
for value in [
interaction_id.as_bytes(),
recipient_id.as_bytes(),
payload.as_bytes(),
] {
bytes.extend_from_slice(&(value.len() as u64).to_be_bytes());
bytes.extend_from_slice(value);
}
bytes
}
pub fn profile_bytes(profile: &Profile) -> Vec<u8> {
format!(
"wyd-profile-v{VERSION}\n{}\n{}",
@@ -67,3 +159,40 @@ pub fn profile_bytes(profile: &Profile) -> Vec<u8> {
pub fn friends_bytes(friends: &[String]) -> Vec<u8> {
format!("wyd-friends-v{VERSION}\n{}", friends.join("\n")).into_bytes()
}
#[cfg(test)]
mod tests {
use super::{InteractionContent, MAX_TEXT_CHARS, interaction_bytes};
#[test]
fn interaction_signature_bytes_are_length_delimited() {
assert_ne!(
interaction_bytes("one", "two", "three\nfour"),
interaction_bytes("one\ntwo", "three", "four"),
);
assert_ne!(
interaction_bytes("id", "recipient-a", "payload"),
interaction_bytes("id", "recipient-b", "payload"),
);
}
#[test]
fn interaction_content_enforces_shared_wire_invariants() {
assert!(InteractionContent::Wave.validate().is_ok());
assert!(
InteractionContent::Text {
text: " ".to_owned()
}
.validate()
.is_err()
);
assert!(
InteractionContent::Text {
text: "x".repeat(MAX_TEXT_CHARS + 1)
}
.validate()
.is_err()
);
assert!(serde_json::from_str::<InteractionContent>(r#"{"type":"unknown"}"#).is_err());
}
}