rustpbx 0.3.19

A SIP PBX implementation in Rust
Documentation
use crate::call::app::{
    AppAction, ApplicationContext, CallApp, CallAppType, CallController, RecordingInfo,
};
use crate::metrics;
use crate::addons::voicemail::models::message;
use crate::addons::voicemail::notifier::{NewMessageEvent, Notifier, NotifierChain};
use crate::addons::voicemail::storage::VoicemailStorage;
use async_trait::async_trait;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing::{info, warn};
use uuid::Uuid;

/// Pre-resolved voicemail prompt paths.
#[derive(Debug, Clone)]
pub struct VoicemailSounds {
    pub greeting_default: String,
    pub beep: String,
    pub saved: String,
}

#[derive(Debug, Clone, PartialEq)]
pub enum VoicemailState {
    Init,
    PlayingGreeting,
    PlayingBeep,
    Recording,
    PlayingSaved,
    Done,
}

pub struct VoicemailApp {
    /// Destination extension (owns the mailbox).
    pub extension: String,
    /// Resolved mailbox UUID from `voicemail_box` table.
    pub box_id: Uuid,
    /// Caller-ID of the incoming call.
    pub caller_id: String,
    /// Email address from `voicemail_box.email` (used for notification).
    pub mailbox_email: Option<String>,
    /// Current state of the IVR state machine.
    pub state: VoicemailState,
    /// Unix timestamp captured just before recording starts (used as filename).
    pub recording_timestamp: i64,
    /// Absolute spool path on the local filesystem (in-progress write).
    pub spool_path: Option<PathBuf>,
    /// Maximum allowed recording duration.
    pub max_duration: Duration,
    /// Shared storage handle (local FS or S3).
    pub storage: Arc<VoicemailStorage>,
    /// Database connection for persisting messages.
    pub db: DatabaseConnection,
    /// Custom greeting audio path from `voicemail_box.greeting_path`.
    /// `None` → use the system default.
    pub custom_greeting_path: Option<String>,
    /// Fan-out notifier (email, webhook, …).
    pub notifier: Arc<NotifierChain>,
    /// Pre-resolved prompt audio paths.
    pub sounds: VoicemailSounds,
}

impl VoicemailApp {
    pub fn new(
        extension: String,
        box_id: Uuid,
        caller_id: String,
        mailbox_email: Option<String>,
        max_duration: Duration,
        storage: Arc<VoicemailStorage>,
        db: DatabaseConnection,
        custom_greeting_path: Option<String>,
        notifier: Arc<NotifierChain>,
        sounds: VoicemailSounds,
    ) -> Self {
        Self {
            extension,
            box_id,
            caller_id,
            mailbox_email,
            state: VoicemailState::Init,
            recording_timestamp: 0,
            spool_path: None,
            max_duration,
            storage,
            db,
            custom_greeting_path,
            notifier,
            sounds,
        }
    }

    /// Resolve the greeting audio path for this extension.
    ///
    /// Priority:
    /// 1. Custom greeting already stored in `voicemail_box.greeting_path`
    ///    (set via `MailboxService::set_greeting_path` after upload).
    /// 2. System default from the addon sounds directory.
    async fn resolve_greeting(&self) -> String {
        if let Some(ref key) = self.custom_greeting_path {
            // For local backends check the file still exists.
            if let Some(local) = self.storage.local_path(key) {
                if local.exists() {
                    return local.to_string_lossy().into_owned();
                }
            } else {
                // S3 backend: trust the stored key is valid.
                return key.clone();
            }
        }
        self.sounds.greeting_default.clone()
    }

    /// Persist the finished recording into the database and upload to storage.
    ///
    /// Runs in a background task so it does not block the IVR state machine.
    async fn persist_message(
        storage: Arc<VoicemailStorage>,
        db: DatabaseConnection,
        notifier: Arc<NotifierChain>,
        box_id: Uuid,
        caller_id: String,
        extension: String,
        mailbox_email: Option<String>,
        spool_path: PathBuf,
        timestamp: i64,
        duration_secs: i32,
    ) {
        // 1. Upload spool file → permanent storage
        let audio_path = match storage
            .upload_recording(&spool_path, &extension, timestamp)
            .await
        {
            Ok(key) => key,
            Err(e) => {
                warn!(
                    extension = %extension,
                    spool = ?spool_path,
                    "voicemail upload failed: {}; keeping spool path", e
                );
                spool_path.to_string_lossy().into_owned()
            }
        };

        info!(
            extension = %extension,
            audio_path = %audio_path,
            duration = duration_secs,
            "voicemail message stored"
        );

        // P2: Voicemail metrics
        metrics::voicemail::message_received(&extension);
        metrics::voicemail::message_duration_seconds(duration_secs as f64, &extension);

        // 2. Insert row into voicemail_message
        let row = message::ActiveModel {
            id: Set(Uuid::new_v4()),
            box_id: Set(box_id),
            caller_id: Set(caller_id.clone()),
            duration: Set(duration_secs),
            audio_path: Set(audio_path.clone()),
            read: Set(false),
            created_at: Set(Utc::now().naive_utc()),
            transcript: Set(None),
            summary: Set(None),
        };

        if let Err(e) = row.insert(&db).await {
            warn!(extension = %extension, "failed to insert voicemail_message: {}", e);
            return;
        }

        // 3. Send notification (best-effort).
        if !notifier.is_empty() {
            let event = NewMessageEvent {
                extension: extension.clone(),
                email: mailbox_email,
                caller_id,
                duration_secs,
                transcript: None,
                audio_url: Some(audio_path),
            };
            if let Err(e) = notifier.notify(&event).await {
                warn!(extension = %extension, "voicemail notifier error: {}", e);
            }
        }
    }
}

// ─── CallApp impl ─────────────────────────────────────────────────────────────

#[async_trait]
impl CallApp for VoicemailApp {
    fn app_type(&self) -> CallAppType {
        CallAppType::Voicemail
    }

    fn name(&self) -> &str {
        "voicemail"
    }

    async fn on_enter(
        &mut self,
        ctrl: &mut CallController,
        _ctx: &ApplicationContext,
    ) -> anyhow::Result<AppAction> {
        ctrl.answer().await?;

        let greeting = self.resolve_greeting().await;
        ctrl.play_audio(greeting, true).await?;
        self.state = VoicemailState::PlayingGreeting;

        Ok(AppAction::Continue)
    }

    async fn on_audio_complete(
        &mut self,
        _track_id: String,
        ctrl: &mut CallController,
        _ctx: &ApplicationContext,
    ) -> anyhow::Result<AppAction> {
        match self.state {
            VoicemailState::PlayingGreeting => {
                ctrl.play_audio(&self.sounds.beep, true).await?;
                self.state = VoicemailState::PlayingBeep;
                Ok(AppAction::Continue)
            }

            VoicemailState::PlayingBeep => {
                // Compute spool path and start recording.
                let ts = Utc::now().timestamp();
                self.recording_timestamp = ts;

                let spool_path = self.storage.spool_path(&self.extension, ts);
                let spool_str = spool_path.to_string_lossy().into_owned();
                self.spool_path = Some(spool_path);

                ctrl.start_recording(spool_str, Some(self.max_duration), false)
                    .await?;
                self.state = VoicemailState::Recording;
                Ok(AppAction::Continue)
            }

            VoicemailState::PlayingSaved => {
                self.state = VoicemailState::Done;
                Ok(AppAction::Hangup {
                    reason: None,
                    code: None,
                })
            }

            _ => Ok(AppAction::Continue),
        }
    }

    async fn on_record_complete(
        &mut self,
        info: RecordingInfo,
        ctrl: &mut CallController,
        _ctx: &ApplicationContext,
    ) -> anyhow::Result<AppAction> {
        if self.state != VoicemailState::Recording {
            return Ok(AppAction::Continue);
        }

        let duration_secs = info.duration.as_secs() as i32;

        match self.spool_path.take() {
            Some(spool_path) if duration_secs > 0 => {
                // Persist asynchronously so we don't block the IVR prompt.
                let storage = self.storage.clone();
                let db = self.db.clone();
                let notifier = self.notifier.clone();
                let box_id = self.box_id;
                let caller_id = self.caller_id.clone();
                let extension = self.extension.clone();
                let email = self.mailbox_email.clone();
                let ts = self.recording_timestamp;

                tokio::spawn(Self::persist_message(
                    storage,
                    db,
                    notifier,
                    box_id,
                    caller_id,
                    extension,
                    email,
                    spool_path,
                    ts,
                    duration_secs,
                ));
            }
            Some(spool_path) => {
                // Zero-duration recording (caller hung up immediately) – discard.
                warn!(spool = ?spool_path, "voicemail recording has zero duration, discarding");
                self.storage.discard_spool(&spool_path).await;
            }
            None => {
                warn!(extension = %self.extension, "on_record_complete with no spool_path");
            }
        }

        // Play confirmation regardless of storage outcome.
        ctrl.play_audio(&self.sounds.saved, true).await?;
        self.state = VoicemailState::PlayingSaved;
        Ok(AppAction::Continue)
    }

    async fn on_dtmf(
        &mut self,
        digit: String,
        ctrl: &mut CallController,
        _ctx: &ApplicationContext,
    ) -> anyhow::Result<AppAction> {
        // Pressing # while recording ends the message early.
        if self.state == VoicemailState::Recording && digit == "#" {
            ctrl.stop_recording().await?;
        }
        Ok(AppAction::Continue)
    }
}