Skip to main content

rvoip_core/
commands.rs

1use crate::adapter::{EndReason, OriginateRequest, RejectReason, TransferTarget};
2use crate::capability::CapabilityDescriptor;
3use crate::conversation::ConversationPolicy;
4use crate::ids::{
5    AiAttachmentId, BridgeId, ConnectionId, ConversationId, ListenerId, ParticipantId, RecordingId,
6    SessionId, TenantId,
7};
8use crate::message::Message;
9use crate::participant::{ParticipantKind, ParticipantRole};
10use crate::session::SessionMedium;
11use std::collections::HashMap;
12use std::fmt;
13
14/// Consumer-facing commands dispatched into the [`crate::Orchestrator`]. Each
15/// carries `tenant_id` and `correlation_id` for tracing/quotas.
16pub enum Command {
17    // --- Conversation lifecycle ---
18    OpenConversation {
19        tenant_id: TenantId,
20        policy: ConversationPolicy,
21        metadata: HashMap<String, String>,
22        correlation_id: String,
23    },
24    CloseConversation {
25        conversation_id: ConversationId,
26        force: bool,
27        correlation_id: String,
28    },
29
30    // --- Session lifecycle ---
31    StartSession {
32        conversation_id: ConversationId,
33        medium: SessionMedium,
34        invitees: Vec<ParticipantId>,
35        correlation_id: String,
36    },
37    EndSession {
38        session_id: SessionId,
39        reason: EndReason,
40        correlation_id: String,
41    },
42    JoinSession {
43        session_id: SessionId,
44        participant_id: ParticipantId,
45        kind: ParticipantKind,
46        role: ParticipantRole,
47        correlation_id: String,
48    },
49    LeaveSession {
50        session_id: SessionId,
51        participant_id: ParticipantId,
52        correlation_id: String,
53    },
54
55    // --- Connection lifecycle ---
56    RouteInboundConnection {
57        connection_id: ConnectionId,
58        action: InboundAction,
59        correlation_id: String,
60    },
61    OriginateConnection {
62        request: OriginateRequest,
63        correlation_id: String,
64    },
65    EndConnection {
66        connection_id: ConnectionId,
67        reason: EndReason,
68        correlation_id: String,
69    },
70
71    // --- Bridging ---
72    BridgeConnections {
73        a: ConnectionId,
74        b: ConnectionId,
75        correlation_id: String,
76    },
77    UnbridgeConnections {
78        bridge_id: BridgeId,
79        correlation_id: String,
80    },
81
82    // --- Transfer ---
83    TransferConnection {
84        connection_id: ConnectionId,
85        target: TransferTarget,
86        correlation_id: String,
87    },
88
89    // --- Per-Connection media control ---
90    Hold {
91        connection_id: ConnectionId,
92        correlation_id: String,
93    },
94    Resume {
95        connection_id: ConnectionId,
96        correlation_id: String,
97    },
98    Mute {
99        connection_id: ConnectionId,
100        direction: MuteDirection,
101        correlation_id: String,
102    },
103    Unmute {
104        connection_id: ConnectionId,
105        direction: MuteDirection,
106        correlation_id: String,
107    },
108    SendMessage {
109        connection_id: ConnectionId,
110        message: Message,
111        correlation_id: String,
112    },
113    SendDtmf {
114        connection_id: ConnectionId,
115        digits: String,
116        duration_ms: u32,
117        correlation_id: String,
118    },
119    PlayAudio {
120        connection_id: ConnectionId,
121        source: AudioSource,
122        correlation_id: String,
123    },
124    RenegotiateMedia {
125        connection_id: ConnectionId,
126        capabilities: CapabilityDescriptor,
127        correlation_id: String,
128    },
129
130    // --- AI / listener attach (AI runtime + recorder taps live in
131    //     consumer / rvoip-harness; rvoip-core just carries the command) ---
132    AttachAi {
133        connection_id: ConnectionId,
134        provider_ref: String,
135        config: HashMap<String, String>,
136        correlation_id: String,
137    },
138    AttachListener {
139        target: ListenerTarget,
140        sink: ListenerSink,
141        correlation_id: String,
142    },
143    Detach {
144        attachment: AttachmentRef,
145        correlation_id: String,
146    },
147
148    // --- Recording / transcription ---
149    StartRecording {
150        target: RecordingTarget,
151        sink: RecordingSink,
152        correlation_id: String,
153    },
154    StopRecording {
155        recording_id: RecordingId,
156        correlation_id: String,
157    },
158    PauseRecording {
159        recording_id: RecordingId,
160        correlation_id: String,
161    },
162    ResumeRecording {
163        recording_id: RecordingId,
164        correlation_id: String,
165    },
166    StartTranscription {
167        target: RecordingTarget,
168        provider_ref: String,
169        correlation_id: String,
170    },
171    StopTranscription {
172        target: RecordingTarget,
173        correlation_id: String,
174    },
175}
176
177impl fmt::Debug for Command {
178    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179        let variant = match self {
180            Self::OpenConversation { .. } => "OpenConversation",
181            Self::CloseConversation { .. } => "CloseConversation",
182            Self::StartSession { .. } => "StartSession",
183            Self::EndSession { .. } => "EndSession",
184            Self::JoinSession { .. } => "JoinSession",
185            Self::LeaveSession { .. } => "LeaveSession",
186            Self::RouteInboundConnection { .. } => "RouteInboundConnection",
187            Self::OriginateConnection { .. } => "OriginateConnection",
188            Self::EndConnection { .. } => "EndConnection",
189            Self::BridgeConnections { .. } => "BridgeConnections",
190            Self::UnbridgeConnections { .. } => "UnbridgeConnections",
191            Self::TransferConnection { .. } => "TransferConnection",
192            Self::Hold { .. } => "Hold",
193            Self::Resume { .. } => "Resume",
194            Self::Mute { .. } => "Mute",
195            Self::Unmute { .. } => "Unmute",
196            Self::SendMessage { .. } => "SendMessage",
197            Self::SendDtmf { .. } => "SendDtmf",
198            Self::PlayAudio { .. } => "PlayAudio",
199            Self::RenegotiateMedia { .. } => "RenegotiateMedia",
200            Self::AttachAi { .. } => "AttachAi",
201            Self::AttachListener { .. } => "AttachListener",
202            Self::Detach { .. } => "Detach",
203            Self::StartRecording { .. } => "StartRecording",
204            Self::StopRecording { .. } => "StopRecording",
205            Self::PauseRecording { .. } => "PauseRecording",
206            Self::ResumeRecording { .. } => "ResumeRecording",
207            Self::StartTranscription { .. } => "StartTranscription",
208            Self::StopTranscription { .. } => "StopTranscription",
209        };
210        formatter
211            .debug_struct("Command")
212            .field("variant", &variant)
213            .finish()
214    }
215}
216
217#[derive(Clone)]
218pub enum InboundAction {
219    Accept {
220        session_id: SessionId,
221        participant_id: ParticipantId,
222    },
223    Reject {
224        reason: RejectReason,
225    },
226    /// Originate an outbound leg and bridge to the inbound (gateway pattern).
227    BridgeTo {
228        session_id: SessionId,
229        outbound: OriginateRequest,
230    },
231}
232
233impl fmt::Debug for InboundAction {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter.write_str(match self {
236            Self::Accept { .. } => "InboundAction::Accept",
237            Self::Reject { .. } => "InboundAction::Reject",
238            Self::BridgeTo { .. } => "InboundAction::BridgeTo",
239        })
240    }
241}
242
243#[derive(Clone, Copy, Debug, Eq, PartialEq)]
244pub enum MuteDirection {
245    Send,
246    Receive,
247    Both,
248}
249
250#[derive(Clone)]
251pub enum AudioSource {
252    Url(String),
253    TtsRequest {
254        provider_ref: String,
255        text: String,
256        voice: Option<String>,
257    },
258}
259
260impl fmt::Debug for AudioSource {
261    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262        match self {
263            Self::Url(url) => formatter
264                .debug_struct("AudioSource::Url")
265                .field("value_present", &!url.is_empty())
266                .finish(),
267            Self::TtsRequest {
268                provider_ref,
269                text,
270                voice,
271            } => formatter
272                .debug_struct("AudioSource::TtsRequest")
273                .field("provider_present", &!provider_ref.is_empty())
274                .field("text_bytes", &text.len())
275                .field("voice_present", &voice.is_some())
276                .finish(),
277        }
278    }
279}
280
281#[derive(Clone)]
282pub enum ListenerTarget {
283    Connection(ConnectionId),
284    Session(SessionId),
285}
286
287impl fmt::Debug for ListenerTarget {
288    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
289        formatter.write_str(match self {
290            Self::Connection(_) => "ListenerTarget::Connection",
291            Self::Session(_) => "ListenerTarget::Session",
292        })
293    }
294}
295
296#[derive(Clone)]
297pub enum ListenerSink {
298    File { path: String },
299    Url(String),
300    Channel,
301}
302
303impl fmt::Debug for ListenerSink {
304    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305        formatter.write_str(match self {
306            Self::File { .. } => "ListenerSink::File",
307            Self::Url(_) => "ListenerSink::Url",
308            Self::Channel => "ListenerSink::Channel",
309        })
310    }
311}
312
313#[derive(Clone)]
314pub enum AttachmentRef {
315    Ai(AiAttachmentId),
316    Listener(ListenerId),
317    Recording(RecordingId),
318}
319
320impl fmt::Debug for AttachmentRef {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        formatter.write_str(match self {
323            Self::Ai(_) => "AttachmentRef::Ai",
324            Self::Listener(_) => "AttachmentRef::Listener",
325            Self::Recording(_) => "AttachmentRef::Recording",
326        })
327    }
328}
329
330#[derive(Clone)]
331pub enum RecordingTarget {
332    Connection(ConnectionId),
333    Session(SessionId),
334}
335
336impl fmt::Debug for RecordingTarget {
337    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338        formatter.write_str(match self {
339            Self::Connection(_) => "RecordingTarget::Connection",
340            Self::Session(_) => "RecordingTarget::Session",
341        })
342    }
343}
344
345#[derive(Clone)]
346pub enum RecordingSink {
347    File { path: String },
348    Url(String),
349}
350
351impl fmt::Debug for RecordingSink {
352    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353        formatter.write_str(match self {
354            Self::File { .. } => "RecordingSink::File",
355            Self::Url(_) => "RecordingSink::Url",
356        })
357    }
358}
359
360#[cfg(test)]
361mod diagnostic_tests {
362    use super::*;
363
364    #[test]
365    fn command_and_nested_payload_debug_never_render_caller_values() {
366        let secret = "credential-canary-value";
367        let connection_id = ConnectionId::new();
368        let commands = [
369            Command::SendDtmf {
370                connection_id: connection_id.clone(),
371                digits: secret.into(),
372                duration_ms: 100,
373                correlation_id: secret.into(),
374            },
375            Command::PlayAudio {
376                connection_id,
377                source: AudioSource::TtsRequest {
378                    provider_ref: secret.into(),
379                    text: secret.into(),
380                    voice: Some(secret.into()),
381                },
382                correlation_id: secret.into(),
383            },
384        ];
385
386        for command in commands {
387            let debug = format!("{command:?}");
388            assert!(!debug.contains(secret));
389            assert!(debug.contains("variant"));
390        }
391
392        for debug in [
393            format!("{:?}", AudioSource::Url(secret.into())),
394            format!(
395                "{:?}",
396                ListenerSink::File {
397                    path: secret.into()
398                }
399            ),
400            format!("{:?}", ListenerSink::Url(secret.into())),
401            format!(
402                "{:?}",
403                RecordingSink::File {
404                    path: secret.into()
405                }
406            ),
407            format!("{:?}", RecordingSink::Url(secret.into())),
408        ] {
409            assert!(!debug.contains(secret));
410        }
411    }
412}