use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OutboundReplyKind {
Text {
body: String,
},
VoiceNote {
#[serde(with = "base64_bytes")]
audio_bytes: Vec<u8>,
mimetype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
transcript: Option<String>,
},
Image {
#[serde(with = "base64_bytes")]
bytes: Vec<u8>,
mimetype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caption: Option<String>,
},
}
impl OutboundReplyKind {
pub fn text(body: impl Into<String>) -> Self {
Self::Text { body: body.into() }
}
pub fn kind_label(&self) -> &'static str {
match self {
Self::Text { .. } => "text",
Self::VoiceNote { .. } => "voice_note",
Self::Image { .. } => "image",
}
}
pub fn as_text_summary(&self) -> &str {
match self {
Self::Text { body } => body,
Self::VoiceNote { transcript, .. } => transcript.as_deref().unwrap_or(""),
Self::Image { caption, .. } => caption.as_deref().unwrap_or(""),
}
}
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutboundReplyContext {
pub agent_id: String,
pub session_id: String,
pub channel: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipient: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
pub conversation_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
}
mod base64_bytes {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&B64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_round_trips() {
let r = OutboundReplyKind::text("hola");
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["kind"], "text");
assert_eq!(v["body"], "hola");
let back: OutboundReplyKind = serde_json::from_value(v).unwrap();
assert_eq!(back, r);
}
#[test]
fn voice_note_round_trips_via_base64() {
let r = OutboundReplyKind::VoiceNote {
audio_bytes: vec![0x49, 0x44, 0x33, 0x04],
mimetype: "audio/mpeg".into(),
transcript: Some("hola".into()),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"kind\":\"voice_note\""));
assert!(s.contains("SUQzBA=="));
let back: OutboundReplyKind = serde_json::from_str(&s).unwrap();
assert_eq!(back, r);
}
#[test]
fn unknown_variant_fails_loud() {
let s = r#"{"kind":"sticker","sticker_id":"x"}"#;
let r: Result<OutboundReplyKind, _> = serde_json::from_str(s);
assert!(r.is_err());
}
#[test]
fn kind_label_matches_variant() {
assert_eq!(OutboundReplyKind::text("a").kind_label(), "text");
}
}