use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::call::domain::{CallCommand, LegId, MediaRuntimeProfile};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
pub success: bool,
pub message: Option<String>,
pub affected_leg: Option<LegId>,
pub media_degraded: bool,
pub degradation_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
impl CommandResult {
pub fn success() -> Self {
Self {
success: true,
message: None,
affected_leg: None,
media_degraded: false,
degradation_reason: None,
data: None,
}
}
pub fn success_with_leg(leg: LegId) -> Self {
Self {
success: true,
message: None,
affected_leg: Some(leg),
media_degraded: false,
degradation_reason: None,
data: None,
}
}
pub fn failure(message: impl Into<String>) -> Self {
Self {
success: false,
message: Some(message.into()),
affected_leg: None,
media_degraded: false,
degradation_reason: None,
data: None,
}
}
pub fn degraded(reason: impl Into<String>) -> Self {
Self {
success: true,
message: None,
affected_leg: None,
media_degraded: true,
degradation_reason: Some(reason.into()),
data: None,
}
}
pub fn not_supported(message: impl Into<String>) -> Self {
Self {
success: false,
message: Some(message.into()),
affected_leg: None,
media_degraded: false,
degradation_reason: None,
data: None,
}
}
pub fn success_with_data(data: serde_json::Value) -> Self {
Self {
success: true,
message: None,
affected_leg: None,
media_degraded: false,
degradation_reason: None,
data: Some(data),
}
}
}
#[derive(Debug, Clone)]
pub struct ExecutionContext {
pub session_id: String,
pub media_profile: MediaRuntimeProfile,
pub source: CommandSource,
pub command_id: Option<String>,
}
impl ExecutionContext {
pub fn new(session_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
media_profile: MediaRuntimeProfile::default(),
source: CommandSource::Internal,
command_id: None,
}
}
pub fn with_media_profile(mut self, profile: MediaRuntimeProfile) -> Self {
self.media_profile = profile;
self
}
pub fn with_source(mut self, source: CommandSource) -> Self {
self.source = source;
self
}
pub fn with_command_id(mut self, id: impl Into<String>) -> Self {
self.command_id = Some(id.into());
self
}
pub fn check_media_capability(&self, cmd: &CallCommand) -> MediaCapabilityCheck {
if cmd.is_signaling_only() {
return MediaCapabilityCheck::Allowed;
}
if !cmd.requires_media() {
return MediaCapabilityCheck::Allowed;
}
match cmd {
CallCommand::Play { .. } => {
if self.media_profile.can_play() {
MediaCapabilityCheck::Allowed
} else {
MediaCapabilityCheck::Degraded {
reason: "playback not supported in bypass mode".to_string(),
}
}
}
CallCommand::StartRecording { .. } => {
if self.media_profile.can_record() {
MediaCapabilityCheck::Allowed
} else {
MediaCapabilityCheck::Denied {
reason: "recording not supported in bypass mode".to_string(),
}
}
}
CallCommand::SupervisorListen { .. }
| CallCommand::SupervisorWhisper { .. }
| CallCommand::SupervisorBarge { .. }
| CallCommand::SupervisorTakeover { .. } => {
if self.media_profile.can_supervise() {
MediaCapabilityCheck::Allowed
} else {
MediaCapabilityCheck::Denied {
reason: "supervisor modes not supported in bypass mode".to_string(),
}
}
}
CallCommand::Hold { music: Some(_), .. } => {
if self.media_profile.supports_media_injection {
MediaCapabilityCheck::Allowed
} else {
MediaCapabilityCheck::Degraded {
reason: "hold music not supported in bypass mode".to_string(),
}
}
}
_ => MediaCapabilityCheck::Allowed,
}
}
}
#[derive(Debug, Clone)]
pub enum MediaCapabilityCheck {
Allowed,
Degraded { reason: String },
Denied { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandSource {
Rwi,
Console,
Internal,
Sip,
}
impl std::fmt::Display for CommandSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CommandSource::Rwi => write!(f, "rwi"),
CommandSource::Console => write!(f, "console"),
CommandSource::Internal => write!(f, "internal"),
CommandSource::Sip => write!(f, "sip"),
}
}
}
#[async_trait]
pub trait CommandExecutor: Send + Sync {
async fn execute(
&self,
ctx: ExecutionContext,
command: CallCommand,
) -> anyhow::Result<CommandResult>;
async fn session_exists(&self, session_id: &str) -> bool;
async fn get_media_profile(&self, session_id: &str) -> Option<MediaRuntimeProfile>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::domain::MediaSource;
#[test]
fn command_result_success() {
let result = CommandResult::success();
assert!(result.success);
assert!(result.message.is_none());
assert!(!result.media_degraded);
}
#[test]
fn command_result_failure() {
let result = CommandResult::failure("test error");
assert!(!result.success);
assert_eq!(result.message, Some("test error".to_string()));
}
#[test]
fn command_result_degraded() {
let result = CommandResult::degraded("bypass mode");
assert!(result.success);
assert!(result.media_degraded);
assert_eq!(result.degradation_reason, Some("bypass mode".to_string()));
}
#[test]
fn execution_context_media_check_signaling() {
let ctx =
ExecutionContext::new("session-1").with_media_profile(MediaRuntimeProfile::degraded());
let cmd = CallCommand::Answer {
leg_id: LegId::new("leg-1"),
};
assert!(matches!(
ctx.check_media_capability(&cmd),
MediaCapabilityCheck::Allowed
));
}
#[test]
fn execution_context_media_check_play_bypass() {
let ctx =
ExecutionContext::new("session-1").with_media_profile(MediaRuntimeProfile::degraded());
let cmd = CallCommand::Play {
leg_id: None,
source: MediaSource::file("test.wav"),
options: None,
};
match ctx.check_media_capability(&cmd) {
MediaCapabilityCheck::Degraded { reason } => {
assert!(reason.contains("bypass"));
}
_ => panic!("Expected Degraded"),
}
}
#[test]
fn execution_context_media_check_record_bypass() {
let ctx =
ExecutionContext::new("session-1").with_media_profile(MediaRuntimeProfile::degraded());
let cmd = CallCommand::StartRecording {
config: crate::call::domain::RecordConfig {
path: "/tmp/rec.wav".to_string(),
max_duration_secs: None,
beep: false,
format: None,
},
};
match ctx.check_media_capability(&cmd) {
MediaCapabilityCheck::Denied { reason } => {
assert!(reason.contains("recording"));
}
_ => panic!("Expected Denied"),
}
}
#[test]
fn execution_context_media_check_record_anchored() {
let ctx =
ExecutionContext::new("session-1").with_media_profile(MediaRuntimeProfile::default());
let cmd = CallCommand::StartRecording {
config: crate::call::domain::RecordConfig {
path: "/tmp/rec.wav".to_string(),
max_duration_secs: None,
beep: false,
format: None,
},
};
assert!(matches!(
ctx.check_media_capability(&cmd),
MediaCapabilityCheck::Allowed
));
}
}