1use std::fmt;
4
5use rvoip_core::ConnectionId;
6use serde_json::Value;
7
8#[derive(Clone)]
9#[non_exhaustive]
10pub enum VapiEvent {
11 StatusUpdate {
12 status: String,
13 ended_reason: Option<String>,
14 },
15 SpeechUpdate {
16 status: String,
17 role: Option<String>,
18 },
19 Transcript {
20 role: Option<String>,
21 transcript_type: Option<String>,
22 transcript: Option<String>,
23 },
24 ModelOutput {
25 output: Option<String>,
26 },
27 ConversationUpdate {
28 conversation: Value,
29 },
30 ToolCall {
31 event_type: String,
32 payload: Value,
33 },
34 EndOfCallReport {
35 report: Value,
36 },
37 Unknown(Value),
38 Malformed {
39 byte_len: usize,
40 },
41}
42
43impl VapiEvent {
44 pub(crate) fn parse(text: &str) -> Self {
45 let Ok(value) = serde_json::from_str::<Value>(text) else {
46 return Self::Malformed {
47 byte_len: text.len(),
48 };
49 };
50 let Some(event_type) = value.get("type").and_then(Value::as_str) else {
51 return Self::Unknown(value);
52 };
53 match event_type {
54 "status-update" => Self::StatusUpdate {
55 status: string_field(&value, "status").unwrap_or_default(),
56 ended_reason: string_field(&value, "endedReason"),
57 },
58 "speech-update" => Self::SpeechUpdate {
59 status: string_field(&value, "status").unwrap_or_default(),
60 role: string_field(&value, "role"),
61 },
62 "transcript" => Self::Transcript {
63 role: string_field(&value, "role"),
64 transcript_type: string_field(&value, "transcriptType"),
65 transcript: string_field(&value, "transcript"),
66 },
67 "model-output" => Self::ModelOutput {
68 output: string_field(&value, "output")
69 .or_else(|| string_field(&value, "modelOutput")),
70 },
71 "conversation-update" => Self::ConversationUpdate {
72 conversation: value.get("conversation").cloned().unwrap_or(Value::Null),
73 },
74 "function-call"
75 | "function-call-result"
76 | "tool-call"
77 | "tool-calls"
78 | "tool-calls-result" => Self::ToolCall {
79 event_type: event_type.to_owned(),
80 payload: value,
81 },
82 "end-of-call-report" => Self::EndOfCallReport { report: value },
83 _ => Self::Unknown(value),
84 }
85 }
86
87 pub(crate) fn is_terminal_status(&self) -> bool {
88 matches!(
89 self,
90 Self::StatusUpdate { status, .. } if status.eq_ignore_ascii_case("ended")
91 ) || matches!(self, Self::EndOfCallReport { .. })
92 }
93}
94
95fn string_field(value: &Value, name: &str) -> Option<String> {
96 value.get(name).and_then(Value::as_str).map(str::to_owned)
97}
98
99impl fmt::Debug for VapiEvent {
100 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self {
102 Self::StatusUpdate {
103 status,
104 ended_reason,
105 } => formatter
106 .debug_struct("StatusUpdate")
107 .field("status_present", &!status.is_empty())
108 .field("ended_reason_present", &ended_reason.is_some())
109 .finish(),
110 Self::SpeechUpdate { status, role } => formatter
111 .debug_struct("SpeechUpdate")
112 .field("status_present", &!status.is_empty())
113 .field("role_present", &role.is_some())
114 .finish(),
115 Self::Transcript {
116 role,
117 transcript_type,
118 transcript,
119 } => formatter
120 .debug_struct("Transcript")
121 .field("role_present", &role.is_some())
122 .field("transcript_type_present", &transcript_type.is_some())
123 .field("transcript_present", &transcript.is_some())
124 .field(
125 "transcript_bytes",
126 &transcript.as_ref().map_or(0, String::len),
127 )
128 .finish(),
129 Self::ModelOutput { output } => formatter
130 .debug_struct("ModelOutput")
131 .field("output_present", &output.is_some())
132 .field("output_bytes", &output.as_ref().map_or(0, String::len))
133 .finish(),
134 Self::ConversationUpdate { conversation } => formatter
135 .debug_struct("ConversationUpdate")
136 .field("entry_count", &conversation.as_array().map_or(0, Vec::len))
137 .finish(),
138 Self::ToolCall { event_type, .. } => formatter
139 .debug_struct("ToolCall")
140 .field("event_type_present", &!event_type.is_empty())
141 .finish(),
142 Self::EndOfCallReport { .. } => formatter.write_str("EndOfCallReport([redacted])"),
143 Self::Unknown(_) => formatter.write_str("Unknown([redacted])"),
144 Self::Malformed { byte_len } => formatter
145 .debug_struct("Malformed")
146 .field("byte_len", byte_len)
147 .finish(),
148 }
149 }
150}
151
152#[derive(Clone)]
153pub struct VapiEventEnvelope {
154 pub connection_id: ConnectionId,
155 pub event: VapiEvent,
156}
157
158impl fmt::Debug for VapiEventEnvelope {
159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160 formatter
161 .debug_struct("VapiEventEnvelope")
162 .field("connection_id", &self.connection_id)
163 .field("event", &self.event)
164 .finish()
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn parses_known_unknown_and_malformed_events() {
174 let transcript = VapiEvent::parse(
175 r#"{"type":"transcript","role":"user","transcriptType":"partial","transcript":"hi"}"#,
176 );
177 assert!(matches!(
178 transcript,
179 VapiEvent::Transcript {
180 transcript: Some(ref text),
181 ..
182 } if text == "hi"
183 ));
184 assert!(matches!(
185 VapiEvent::parse(r#"{"type":"future-event","secret":"value"}"#),
186 VapiEvent::Unknown(_)
187 ));
188 assert!(matches!(
189 VapiEvent::parse("{bad"),
190 VapiEvent::Malformed { byte_len: 4 }
191 ));
192 }
193
194 #[test]
195 fn diagnostics_do_not_render_event_content() {
196 let event = VapiEvent::parse(
197 r#"{"type":"transcript","role":"role-canary","transcript":"content-canary"}"#,
198 );
199 let debug = format!("{event:?}");
200 assert!(!debug.contains("role-canary"));
201 assert!(!debug.contains("content-canary"));
202 }
203}