Skip to main content

rvoip_vapi/
events.rs

1//! Typed, forward-compatible Vapi event surface.
2
3use 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}