use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use super::{HangupCommand, LegId, MediaSource, RingbackPolicy};
pub type CallCommandTx = mpsc::Sender<CallCommand>;
pub type CallCommandRx = mpsc::Receiver<CallCommand>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CallCommand {
Answer {
leg_id: LegId,
},
Reject {
leg_id: LegId,
reason: Option<String>,
},
Ring {
leg_id: LegId,
ringback: Option<RingbackPolicy>,
},
Hangup(HangupCommand),
Bridge {
leg_a: LegId,
leg_b: LegId,
mode: P2PMode,
},
Unbridge {
leg_id: LegId,
},
BridgeCrossSession {
session_a: String,
leg_a: LegId,
session_b: String,
leg_b: LegId,
},
Transfer {
leg_id: LegId,
target: String,
attended: bool,
},
TransferComplete {
consult_leg: LegId,
},
TransferCancel {
consult_leg: LegId,
},
TransferCompleteCrossSession {
from_session: String,
leg_id: LegId,
into_conference: String,
},
Hold {
leg_id: LegId,
music: Option<MediaSource>,
},
Unhold {
leg_id: LegId,
},
Play {
leg_id: Option<LegId>,
source: MediaSource,
options: Option<PlayOptions>,
},
StopPlayback {
leg_id: Option<LegId>,
},
SendDtmf {
leg_id: LegId,
digits: String,
},
DtmfCollect {
leg_id: Option<LegId>,
min_digits: u32,
max_digits: u32,
timeout_ms: u64,
terminator: Option<char>,
},
StartRecording {
config: RecordConfig,
},
PauseRecording,
ResumeRecording,
StopRecording,
SupervisorListen {
supervisor_leg: LegId,
target_leg: LegId,
supervisor_session_id: Option<String>,
},
SupervisorWhisper {
supervisor_leg: LegId,
target_leg: LegId,
supervisor_session_id: Option<String>,
},
SupervisorBarge {
supervisor_leg: LegId,
target_leg: LegId,
supervisor_session_id: Option<String>,
},
SupervisorTakeover {
supervisor_leg: LegId,
target_leg: LegId,
supervisor_session_id: Option<String>,
},
SupervisorStop {
supervisor_leg: LegId,
},
ConferenceCreate {
conf_id: String,
options: ConferenceOptions,
},
ConferenceAdd {
conf_id: String,
leg_id: LegId,
},
ConferenceRemove {
conf_id: String,
leg_id: LegId,
},
ConferenceMute {
conf_id: String,
leg_id: LegId,
},
ConferenceUnmute {
conf_id: String,
leg_id: LegId,
},
ConferenceDestroy {
conf_id: String,
},
ConferenceEnd {
conf_id: String,
host_leg_id: LegId,
},
ConferenceKick {
conf_id: String,
leg_id: LegId,
},
ConferenceMuteAll {
conf_id: String,
},
ConferenceInfo {
conf_id: String,
},
ConferenceList,
QueueEnqueue {
leg_id: LegId,
queue_id: String,
priority: Option<u32>,
},
QueueDequeue {
leg_id: LegId,
},
StartApp {
app_name: String,
params: Option<serde_json::Value>,
auto_answer: bool,
},
StopApp {
reason: Option<String>,
},
InjectAppEvent {
event: AppEvent,
},
HandleReInvite {
leg_id: LegId,
sdp: String,
},
RefreshSession,
MuteTrack {
track_id: String,
},
UnmuteTrack {
track_id: String,
},
SendSipMessage {
content_type: String,
body: String,
},
SendSipNotify {
event: String,
content_type: String,
body: String,
},
JoinMixer {
mixer_id: String,
},
LeaveMixer,
SendSipOptionsPing,
LegAdd {
target: String,
leg_id: Option<LegId>,
},
LegRemove {
leg_id: LegId,
},
LegConnected {
leg_id: LegId,
answer_sdp: Option<String>,
},
LegFailed {
leg_id: LegId,
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum P2PMode {
#[default]
Audio,
Video,
AudioVideo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayOptions {
pub loop_playback: bool,
pub await_completion: bool,
pub interrupt_on_dtmf: bool,
pub track_id: Option<String>,
pub send_progress: bool,
}
impl Default for PlayOptions {
fn default() -> Self {
Self {
loop_playback: false,
await_completion: false,
interrupt_on_dtmf: true,
track_id: None,
send_progress: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordConfig {
pub path: String,
pub max_duration_secs: Option<u32>,
pub beep: bool,
pub format: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConferenceOptions {
pub max_participants: Option<u32>,
pub record: bool,
pub record_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppEvent {
Dtmf { digit: String },
AudioComplete { track_id: String, interrupted: bool },
RecordingComplete { recording_id: String, path: String },
Custom {
name: String,
data: serde_json::Value,
},
Timeout { timer_id: String },
}
impl CallCommand {
pub fn requires_media(&self) -> bool {
matches!(
self,
CallCommand::Play { .. }
| CallCommand::StartRecording { .. }
| CallCommand::SupervisorListen { .. }
| CallCommand::SupervisorWhisper { .. }
| CallCommand::SupervisorBarge { .. }
| CallCommand::SupervisorTakeover { .. }
| CallCommand::Hold { music: Some(_), .. }
)
}
pub fn is_signaling_only(&self) -> bool {
matches!(
self,
CallCommand::Answer { .. }
| CallCommand::Reject { .. }
| CallCommand::Hangup(_)
| CallCommand::Transfer { .. }
| CallCommand::Hold { music: None, .. }
| CallCommand::Unhold { .. }
)
}
pub fn target_leg(&self) -> Option<&LegId> {
match self {
CallCommand::Answer { leg_id } => Some(leg_id),
CallCommand::Reject { leg_id, .. } => Some(leg_id),
CallCommand::Ring { leg_id, .. } => Some(leg_id),
CallCommand::Hangup(cmd) => cmd.leg_id.as_ref(),
CallCommand::Bridge { leg_a, .. } => Some(leg_a),
CallCommand::Unbridge { leg_id } => Some(leg_id),
CallCommand::Transfer { leg_id, .. } => Some(leg_id),
CallCommand::Hold { leg_id, .. } => Some(leg_id),
CallCommand::Unhold { leg_id } => Some(leg_id),
CallCommand::Play {
leg_id: Some(leg_id),
..
} => Some(leg_id),
CallCommand::StopPlayback {
leg_id: Some(leg_id),
} => Some(leg_id),
CallCommand::SendDtmf { leg_id, .. } => Some(leg_id),
CallCommand::SupervisorListen { supervisor_leg, .. } => Some(supervisor_leg),
CallCommand::SupervisorWhisper { supervisor_leg, .. } => Some(supervisor_leg),
CallCommand::SupervisorBarge { supervisor_leg, .. } => Some(supervisor_leg),
CallCommand::SupervisorTakeover { supervisor_leg, .. } => Some(supervisor_leg),
CallCommand::SupervisorStop { supervisor_leg } => Some(supervisor_leg),
CallCommand::ConferenceAdd { leg_id, .. } => Some(leg_id),
CallCommand::ConferenceRemove { leg_id, .. } => Some(leg_id),
CallCommand::ConferenceMute { leg_id, .. } => Some(leg_id),
CallCommand::ConferenceUnmute { leg_id, .. } => Some(leg_id),
CallCommand::ConferenceKick { leg_id, .. } => Some(leg_id),
CallCommand::ConferenceEnd { host_leg_id, .. } => Some(host_leg_id),
CallCommand::QueueEnqueue { leg_id, .. } => Some(leg_id),
CallCommand::QueueDequeue { leg_id } => Some(leg_id),
CallCommand::HandleReInvite { leg_id, .. } => Some(leg_id),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_command_requires_media() {
let play = CallCommand::Play {
leg_id: None,
source: MediaSource::file("test.wav"),
options: None,
};
assert!(play.requires_media());
let answer = CallCommand::Answer {
leg_id: LegId::new("leg-1"),
};
assert!(!answer.requires_media());
}
#[test]
fn call_command_signaling_only() {
let answer = CallCommand::Answer {
leg_id: LegId::new("leg-1"),
};
assert!(answer.is_signaling_only());
let play = CallCommand::Play {
leg_id: None,
source: MediaSource::file("test.wav"),
options: None,
};
assert!(!play.is_signaling_only());
}
#[test]
fn call_command_target_leg() {
let answer = CallCommand::Answer {
leg_id: LegId::new("leg-1"),
};
assert_eq!(answer.target_leg().map(|l| l.as_str()), Some("leg-1"));
let start_recording = CallCommand::StartRecording {
config: RecordConfig {
path: "/tmp/rec.wav".to_string(),
max_duration_secs: None,
beep: false,
format: None,
},
};
assert!(start_recording.target_leg().is_none());
}
#[test]
fn call_command_conference_new_variants() {
let kick = CallCommand::ConferenceKick {
conf_id: "conf-1".to_string(),
leg_id: LegId::new("leg-1"),
};
assert_eq!(kick.target_leg().map(|l| l.as_str()), Some("leg-1"));
let mute_all = CallCommand::ConferenceMuteAll {
conf_id: "conf-1".to_string(),
};
assert!(mute_all.target_leg().is_none());
let info = CallCommand::ConferenceInfo {
conf_id: "conf-1".to_string(),
};
assert!(info.target_leg().is_none());
let list = CallCommand::ConferenceList;
assert!(list.target_leg().is_none());
}
#[test]
fn call_command_serialization_roundtrip() {
let kick = CallCommand::ConferenceKick {
conf_id: "conf-1".to_string(),
leg_id: LegId::new("leg-1"),
};
let json = serde_json::to_string(&kick).unwrap();
let parsed: CallCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, CallCommand::ConferenceKick { .. }));
let mute_all = CallCommand::ConferenceMuteAll {
conf_id: "conf-1".to_string(),
};
let json = serde_json::to_string(&mute_all).unwrap();
let parsed: CallCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, CallCommand::ConferenceMuteAll { .. }));
let list = CallCommand::ConferenceList;
let json = serde_json::to_string(&list).unwrap();
let parsed: CallCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, CallCommand::ConferenceList));
}
}