use std::collections::BTreeSet;
use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
use crate::protocol::{Behavior, Expression, Manifest, ProtocolError};
pub const HELLO: &str = "hello";
pub const ACK: &str = "ack";
pub const INPUT_TEXT: &str = "input.text";
pub const PERSON_DETECTED: &str = "observation.person.detected";
pub const SPEECH_ACTIVITY: &str = "observation.speech.activity";
pub const WELCOME: &str = "welcome";
pub const INTENT_BEHAVIOR: &str = "intent.behavior";
pub const INTENT_EXPRESSION: &str = "intent.expression";
pub const INTENT_BEAT: &str = "intent.beat";
pub const INTENT_IDENTIFY: &str = "intent.identify";
pub const INTENT_TEXT: &str = "intent.text";
pub const MAX_INPUT_TEXT: usize = 4_000;
pub const MAX_INTENT_TEXT: usize = 16_000;
const MAX_ID: usize = 64;
pub fn envelope(message_type: &str, payload: Value) -> Value {
json!({
"type": message_type,
"id": uuid::Uuid::new_v4().simple().to_string(),
"at": Utc::now().to_rfc3339_opts(SecondsFormat::Micros, false),
"payload": payload,
})
}
pub fn hello(manifest: &Manifest, token: &str) -> Value {
envelope(HELLO, json!({ "manifest": manifest.to_json(), "token": token }))
}
pub fn ack(intent_id: &str) -> Value {
envelope(ACK, json!({ "of": intent_id }))
}
pub fn input_text(text: &str) -> Result<Value, ProtocolError> {
let length = text.chars().count();
if !(1..=MAX_INPUT_TEXT).contains(&length) {
return Err(ProtocolError::new(format!("typed text has 1 to {MAX_INPUT_TEXT} characters, not {length}")));
}
Ok(envelope(INPUT_TEXT, json!({ "text": text })))
}
pub fn person_detected(count: u32, confidence: f64) -> Result<Value, ProtocolError> {
Ok(envelope(PERSON_DETECTED, json!({ "count": count, "confidence": unit(confidence)? })))
}
pub fn speech_activity(active: bool) -> Value {
envelope(SPEECH_ACTIVITY, json!({ "active": active }))
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Welcome {
pub device_id: String,
pub room: String,
pub capabilities: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Intent {
Behavior(Behavior),
Expression {
expression: Expression,
intensity: f64,
},
Beat,
Identify,
Text {
text: String,
last: bool,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
Welcome(Welcome),
Intent(Intent),
Unknown(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Received {
pub id: String,
pub at: DateTime<Utc>,
pub message: Message,
}
pub fn parse(text: &str) -> Result<Received, ProtocolError> {
let data: Value = serde_json::from_str(text).map_err(|error| ProtocolError::new(format!("not JSON: {error}")))?;
decode(data)
}
pub fn decode(data: Value) -> Result<Received, ProtocolError> {
let Value::Object(mut data) = data else {
return Err(ProtocolError::new("a message is a JSON object"));
};
let keys: BTreeSet<&str> = data.keys().map(String::as_str).collect();
if keys != BTreeSet::from(["type", "id", "at", "payload"]) {
return Err(ProtocolError::new("a message is an object with exactly type, id, at and payload"));
}
let message_type = match data.remove("type") {
Some(Value::String(t)) => t,
other => {
return Err(ProtocolError::new(format!("message type {other:?} is not a string")));
}
};
let id = match data.remove("id") {
Some(Value::String(id)) if !id.is_empty() && id.chars().count() <= MAX_ID => id,
_ => {
return Err(ProtocolError::new("message id must be a non-empty string of at most 64 characters"));
}
};
let at = match data.remove("at") {
Some(Value::String(at)) => parse_time(&at)?,
other => {
return Err(ProtocolError::new(format!("message time {other:?} is not an ISO 8601 date-time")));
}
};
let payload = match data.remove("payload") {
Some(Value::Object(payload)) => payload,
_ => {
return Err(ProtocolError::new(format!("{message_type}: the payload must be an object")));
}
};
let message = match message_type.as_str() {
WELCOME => Message::Welcome(payload_of(&message_type, payload)?),
INTENT_BEHAVIOR => {
let p: BehaviorPayload = payload_of(&message_type, payload)?;
Message::Intent(Intent::Behavior(p.behavior))
}
INTENT_EXPRESSION => {
let p: ExpressionPayload = payload_of(&message_type, payload)?;
let intensity = unit(p.intensity.unwrap_or(1.0))
.map_err(|e| ProtocolError::new(format!("{message_type}.intensity: {e}")))?;
Message::Intent(Intent::Expression { expression: p.expression, intensity })
}
INTENT_BEAT => {
let _: EmptyPayload = payload_of(&message_type, payload)?;
Message::Intent(Intent::Beat)
}
INTENT_IDENTIFY => {
let _: EmptyPayload = payload_of(&message_type, payload)?;
Message::Intent(Intent::Identify)
}
INTENT_TEXT => {
let p: TextPayload = payload_of(&message_type, payload)?;
let length = p.text.chars().count();
if !(1..=MAX_INTENT_TEXT).contains(&length) {
return Err(ProtocolError::new(format!(
"{message_type}.text has 1 to {MAX_INTENT_TEXT} characters, not {length}"
)));
}
Message::Intent(Intent::Text { text: p.text, last: p.r#final.unwrap_or(true) })
}
_ => Message::Unknown(message_type),
};
Ok(Received { id, at, message })
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BehaviorPayload {
behavior: Behavior,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ExpressionPayload {
expression: Expression,
intensity: Option<f64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EmptyPayload {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TextPayload {
text: String,
r#final: Option<bool>,
}
fn payload_of<T: DeserializeOwned>(message_type: &str, payload: Map<String, Value>) -> Result<T, ProtocolError> {
serde_json::from_value(Value::Object(payload))
.map_err(|error| ProtocolError::new(format!("{message_type}: {error}")))
}
fn parse_time(text: &str) -> Result<DateTime<Utc>, ProtocolError> {
if let Ok(at) = DateTime::parse_from_rfc3339(text) {
return Ok(at.with_timezone(&Utc));
}
NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f")
.map(|naive| naive.and_utc())
.map_err(|_| ProtocolError::new(format!("message time {text:?} is not an ISO 8601 date-time")))
}
fn unit(value: f64) -> Result<f64, ProtocolError> {
if (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(ProtocolError::new(format!("expected a number between 0 and 1, not {value}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn message(message_type: &str, payload: Value) -> String {
json!({"type": message_type, "id": "m-1", "at": "2026-10-30T12:00:00.123456+00:00", "payload": payload})
.to_string()
}
fn nestor(text: &str) -> Received {
parse(text).unwrap()
}
#[test]
fn the_core_s_messages_are_read() {
let welcome =
nestor(&message(WELCOME, json!({"device_id": "a", "room": "salon", "capabilities": ["audio.capture"]})));
assert_eq!(welcome.id, "m-1");
assert_eq!(
welcome.message,
Message::Welcome(Welcome {
device_id: "a".into(),
room: "salon".into(),
capabilities: BTreeSet::from(["audio.capture".to_owned()]),
})
);
assert_eq!(
nestor(&message(INTENT_BEHAVIOR, json!({"behavior": "thinking"}))).message,
Message::Intent(Intent::Behavior(Behavior::Thinking))
);
assert_eq!(
nestor(&message(INTENT_EXPRESSION, json!({"expression": "sigh", "intensity": 0.4}))).message,
Message::Intent(Intent::Expression { expression: Expression::Sigh, intensity: 0.4 })
);
assert_eq!(
nestor(&message(INTENT_EXPRESSION, json!({"expression": "joy"}))).message,
Message::Intent(Intent::Expression { expression: Expression::Joy, intensity: 1.0 })
);
assert_eq!(nestor(&message(INTENT_BEAT, json!({}))).message, Message::Intent(Intent::Beat));
assert_eq!(nestor(&message(INTENT_IDENTIFY, json!({}))).message, Message::Intent(Intent::Identify));
assert_eq!(
nestor(&message(INTENT_TEXT, json!({"text": "Je regarde.", "final": false}))).message,
Message::Intent(Intent::Text { text: "Je regarde.".into(), last: false })
);
assert_eq!(
nestor(&message(INTENT_TEXT, json!({"text": "Il fait 21 °C."}))).message,
Message::Intent(Intent::Text { text: "Il fait 21 °C.".into(), last: true }),
"an unprompted message is the last of its turn"
);
}
#[test]
fn a_message_type_from_a_newer_core_is_not_an_error() {
assert_eq!(
nestor(&message("intent.dance", json!({"style": "waltz"}))).message,
Message::Unknown("intent.dance".into())
);
}
#[test]
fn off_contract_messages_are_refused_like_the_server_does() {
let wrong = [
"[]".to_owned(),
"not json".to_owned(),
message(INTENT_BEHAVIOR, json!({"behavior": "dancing"})),
message(INTENT_BEHAVIOR, json!({"behavior": "idle", "extra": 1})),
message(INTENT_BEHAVIOR, json!({})),
message(INTENT_EXPRESSION, json!({"expression": "joy", "intensity": 1.5})),
message(INTENT_EXPRESSION, json!({"expression": "joy", "intensity": true})),
message(INTENT_BEAT, json!({"x": 1})),
message(INTENT_IDENTIFY, json!({"seconds": 5})),
message(WELCOME, json!({"device_id": "a", "room": "b"})),
message(INTENT_TEXT, json!({"text": ""})),
message(INTENT_TEXT, json!({"text": "é".repeat(16_001)})),
message(INTENT_TEXT, json!({"text": "ok", "final": "yes"})),
message(INTENT_TEXT, json!({"text": "ok", "markdown": true})),
json!({"type": "intent.beat", "id": "m", "at": "2026-10-30T12:00:00Z"}).to_string(),
json!({"type": "intent.beat", "id": "m", "at": "2026-10-30T12:00:00Z", "payload": {}, "x": 1}).to_string(),
json!({"type": "intent.beat", "id": "", "at": "2026-10-30T12:00:00Z", "payload": {}}).to_string(),
json!({"type": "intent.beat", "id": "m".repeat(65), "at": "2026-10-30T12:00:00Z", "payload": {}})
.to_string(),
json!({"type": "intent.beat", "id": "m", "at": "yesterday", "payload": {}}).to_string(),
json!({"type": "intent.beat", "id": "m", "at": "2026-10-30T12:00:00Z", "payload": []}).to_string(),
json!({"type": 3, "id": "m", "at": "2026-10-30T12:00:00Z", "payload": {}}).to_string(),
];
for text in wrong {
assert!(parse(&text).is_err(), "accepted {text}");
}
}
#[test]
fn a_time_without_offset_is_utc() {
let received =
nestor(&json!({"type": "intent.beat", "id": "m", "at": "2026-10-30T12:00:00", "payload": {}}).to_string());
assert_eq!(received.at.to_rfc3339(), "2026-10-30T12:00:00+00:00");
}
#[test]
fn what_the_satellite_sends_has_a_fresh_id_and_a_utc_time() -> Result<(), ProtocolError> {
let (a, b) = (ack("intent-1"), ack("intent-1"));
assert_ne!(a["id"], b["id"]);
assert_eq!(a["payload"], json!({"of": "intent-1"}));
assert!(a["at"].as_str().unwrap().ends_with("+00:00"));
assert!(person_detected(1, 1.2).is_err());
assert!(person_detected(1, f64::NAN).is_err());
assert_eq!(person_detected(2, 0.5).unwrap()["payload"], json!({"count": 2, "confidence": 0.5}));
assert_eq!(speech_activity(true)["payload"], json!({"active": true}));
assert_eq!(input_text("Allume le salon")?["payload"], json!({"text": "Allume le salon"}));
assert!(input_text("").is_err());
assert!(input_text(&"é".repeat(4_000)).is_ok(), "characters, not bytes");
assert!(input_text(&"é".repeat(4_001)).is_err());
let manifest = Manifest::new(crate::Device::new("a", "m", "salon")?, ["text.input"])?;
assert_eq!(hello(&manifest, "nst1_x")["payload"]["token"], "nst1_x");
Ok(())
}
}