mhome-conversation-api 0.8.0

Typed wire contract for the mHome conversation API
Documentation
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;

pub const FIRST_PARTY_SURFACE_ID: &str = "meow-link";
const MESSAGING_SURFACE_PREFIX: &str = "messaging";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessagingSurfaceId {
    pub provider: String,
    pub account_id: String,
    pub conversation_id: String,
}

pub fn messaging_surface_id(
    provider: &str,
    account_id: &str,
    conversation_id: &str,
) -> Option<String> {
    let provider = normalize_provider(provider)?;
    let account_id = account_id.trim();
    let conversation_id = conversation_id.trim();
    if account_id.is_empty() || conversation_id.is_empty() {
        return None;
    }
    Some(format!(
        "{MESSAGING_SURFACE_PREFIX}:{provider}:{}:{}",
        encode_component(account_id),
        encode_component(conversation_id)
    ))
}

pub fn parse_messaging_surface_id(surface_id: &str) -> Option<MessagingSurfaceId> {
    let mut parts = surface_id.split(':');
    if parts.next()? != MESSAGING_SURFACE_PREFIX {
        return None;
    }
    let provider = normalize_provider(parts.next()?)?;
    let account_id = decode_component(parts.next()?)?;
    let conversation_id = decode_component(parts.next()?)?;
    if parts.next().is_some() || account_id.is_empty() || conversation_id.is_empty() {
        return None;
    }
    Some(MessagingSurfaceId {
        provider,
        account_id,
        conversation_id,
    })
}

fn normalize_provider(provider: &str) -> Option<String> {
    let provider = provider.trim().to_ascii_lowercase();
    (!provider.is_empty()
        && provider
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'))
    .then_some(provider)
}

fn encode_component(value: &str) -> String {
    URL_SAFE_NO_PAD.encode(value.as_bytes())
}

fn decode_component(value: &str) -> Option<String> {
    String::from_utf8(URL_SAFE_NO_PAD.decode(value).ok()?).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn messaging_surface_round_trips_without_delimiter_ambiguity() {
        let surface = messaging_surface_id(" Telegram ", "bot:1", "chat:2").unwrap();
        assert_eq!(surface, "messaging:telegram:Ym90OjE:Y2hhdDoy");
        assert_eq!(
            parse_messaging_surface_id(&surface),
            Some(MessagingSurfaceId {
                provider: "telegram".to_string(),
                account_id: "bot:1".to_string(),
                conversation_id: "chat:2".to_string(),
            })
        );
    }

    #[test]
    fn invalid_or_empty_components_are_rejected() {
        assert!(messaging_surface_id("not-valid!", "a", "c").is_none());
        assert!(messaging_surface_id("feishu", "", "c").is_none());
        assert!(parse_messaging_surface_id("messaging:feishu:bad!:bad!").is_none());
    }
}