package service import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/pkg/types" ) // VoiceAgentService handles voice agent config and session operations type VoiceAgentService struct { pool *pgxpool.Pool logger zerolog.Logger } // NewVoiceAgentService creates a new voice agent service func NewVoiceAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *VoiceAgentService { return &VoiceAgentService{ pool: pool, logger: logger.With().Str("service", "voice_agent").Logger(), } } // ============================================================================ // Voice Agent Configs // ============================================================================ // ListConfigs lists voice agent configs with optional filters func (s *VoiceAgentService) ListConfigs(ctx context.Context, params types.ListParams, tenantID *uuid.UUID) ([]types.VoiceAgentConfig, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM voice_agent_configs WHERE 1=1` listQuery := `SELECT id, tenant_id, agent_id, greeting_text, goodbye_text, voice_id, language, stt_provider, stt_model, tts_provider, tts_model, max_call_duration_seconds, silence_timeout_seconds, barge_in_enabled, vad_sensitivity, transfer_enabled, transfer_number, business_hours_enabled, business_hours, after_hours_text, is_active, created_at, updated_at FROM voice_agent_configs WHERE 1=1` args := []interface{}{} argIdx := 1 if tenantID != nil { countQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx) listQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx) args = append(args, *tenantID) argIdx++ } if params.Search != "" { countQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx) listQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() configs := make([]types.VoiceAgentConfig, 0) for rows.Next() { var c types.VoiceAgentConfig var businessHours []byte if err := rows.Scan( &c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText, &c.VoiceID, &c.Language, &c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel, &c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds, &c.BargeInEnabled, &c.VADSensitivity, &c.TransferEnabled, &c.TransferNumber, &c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, ); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } c.BusinessHours = json.RawMessage(businessHours) configs = append(configs, c) } return configs, total, nil } // GetConfig gets a voice agent config by ID func (s *VoiceAgentService) GetConfig(ctx context.Context, id uuid.UUID) (*types.VoiceAgentConfig, error) { var c types.VoiceAgentConfig var businessHours []byte err := s.pool.QueryRow(ctx, `SELECT id, tenant_id, agent_id, greeting_text, goodbye_text, voice_id, language, stt_provider, stt_model, tts_provider, tts_model, max_call_duration_seconds, silence_timeout_seconds, barge_in_enabled, vad_sensitivity, transfer_enabled, transfer_number, business_hours_enabled, business_hours, after_hours_text, is_active, created_at, updated_at FROM voice_agent_configs WHERE id = $1`, id). Scan( &c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText, &c.VoiceID, &c.Language, &c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel, &c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds, &c.BargeInEnabled, &c.VADSensitivity, &c.TransferEnabled, &c.TransferNumber, &c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("not found: %w", err) } c.BusinessHours = json.RawMessage(businessHours) return &c, nil } // CreateConfig creates a new voice agent config func (s *VoiceAgentService) CreateConfig(ctx context.Context, req *types.VoiceAgentConfigCreate) (*types.VoiceAgentConfig, error) { // Set defaults greeting := req.GreetingText if greeting == "" { greeting = "Hello, how can I help you today?" } goodbye := req.GoodbyeText if goodbye == "" { goodbye = "Goodbye, have a great day." } voiceID := req.VoiceID if voiceID == "" { voiceID = "alloy" } lang := req.Language if lang == "" { lang = "en" } maxDuration := 1800 if req.MaxCallDurationSeconds != nil { maxDuration = *req.MaxCallDurationSeconds } silenceTimeout := 30 if req.SilenceTimeoutSeconds != nil { silenceTimeout = *req.SilenceTimeoutSeconds } bargeIn := true if req.BargeInEnabled != nil { bargeIn = *req.BargeInEnabled } vadSens := req.VADSensitivity if vadSens == "" { vadSens = "medium" } transfer := true if req.TransferEnabled != nil { transfer = *req.TransferEnabled } bizHoursEnabled := false if req.BusinessHoursEnabled != nil { bizHoursEnabled = *req.BusinessHoursEnabled } bizHours := req.BusinessHours if len(bizHours) == 0 { bizHours = json.RawMessage(`{}`) } var c types.VoiceAgentConfig var businessHoursOut []byte err := s.pool.QueryRow(ctx, `INSERT INTO voice_agent_configs ( tenant_id, agent_id, greeting_text, goodbye_text, voice_id, language, stt_provider, stt_model, tts_provider, tts_model, max_call_duration_seconds, silence_timeout_seconds, barge_in_enabled, vad_sensitivity, transfer_enabled, transfer_number, business_hours_enabled, business_hours, after_hours_text ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text, voice_id, language, stt_provider, stt_model, tts_provider, tts_model, max_call_duration_seconds, silence_timeout_seconds, barge_in_enabled, vad_sensitivity, transfer_enabled, transfer_number, business_hours_enabled, business_hours, after_hours_text, is_active, created_at, updated_at`, req.TenantID, req.AgentID, greeting, goodbye, voiceID, lang, req.STTProvider, req.STTModel, req.TTSProvider, req.TTSModel, maxDuration, silenceTimeout, bargeIn, vadSens, transfer, req.TransferNumber, bizHoursEnabled, bizHours, req.AfterHoursText, ).Scan( &c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText, &c.VoiceID, &c.Language, &c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel, &c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds, &c.BargeInEnabled, &c.VADSensitivity, &c.TransferEnabled, &c.TransferNumber, &c.BusinessHoursEnabled, &businessHoursOut, &c.AfterHoursText, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } c.BusinessHours = json.RawMessage(businessHoursOut) s.logger.Info().Str("id", c.ID.String()).Str("agentId", c.AgentID.String()).Msg("Created voice agent config") return &c, nil } // UpdateConfig updates a voice agent config func (s *VoiceAgentService) UpdateConfig(ctx context.Context, id uuid.UUID, req *types.VoiceAgentConfigUpdate) (*types.VoiceAgentConfig, error) { // Build dynamic SET clause setClauses := []string{} args := []interface{}{} argIdx := 1 addField := func(clause string, val interface{}) { setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx)) args = append(args, val) argIdx++ } if req.GreetingText != nil { addField("greeting_text", *req.GreetingText) } if req.GoodbyeText != nil { addField("goodbye_text", *req.GoodbyeText) } if req.VoiceID != nil { addField("voice_id", *req.VoiceID) } if req.Language != nil { addField("language", *req.Language) } if req.STTProvider != nil { addField("stt_provider", *req.STTProvider) } if req.STTModel != nil { addField("stt_model", *req.STTModel) } if req.TTSProvider != nil { addField("tts_provider", *req.TTSProvider) } if req.TTSModel != nil { addField("tts_model", *req.TTSModel) } if req.MaxCallDurationSeconds != nil { addField("max_call_duration_seconds", *req.MaxCallDurationSeconds) } if req.SilenceTimeoutSeconds != nil { addField("silence_timeout_seconds", *req.SilenceTimeoutSeconds) } if req.BargeInEnabled != nil { addField("barge_in_enabled", *req.BargeInEnabled) } if req.VADSensitivity != nil { addField("vad_sensitivity", *req.VADSensitivity) } if req.TransferEnabled != nil { addField("transfer_enabled", *req.TransferEnabled) } if req.TransferNumber != nil { addField("transfer_number", *req.TransferNumber) } if req.BusinessHoursEnabled != nil { addField("business_hours_enabled", *req.BusinessHoursEnabled) } if len(req.BusinessHours) > 0 { addField("business_hours", req.BusinessHours) } if req.AfterHoursText != nil { addField("after_hours_text", *req.AfterHoursText) } if req.IsActive != nil { addField("is_active", *req.IsActive) } if len(setClauses) == 0 { return s.GetConfig(ctx, id) } // Always update updated_at setClauses = append(setClauses, "updated_at = NOW()") query := fmt.Sprintf("UPDATE voice_agent_configs SET %s WHERE id = $%d", joinClauses(setClauses), argIdx) args = append(args, id) query += ` RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text, voice_id, language, stt_provider, stt_model, tts_provider, tts_model, max_call_duration_seconds, silence_timeout_seconds, barge_in_enabled, vad_sensitivity, transfer_enabled, transfer_number, business_hours_enabled, business_hours, after_hours_text, is_active, created_at, updated_at` var c types.VoiceAgentConfig var businessHours []byte err := s.pool.QueryRow(ctx, query, args...).Scan( &c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText, &c.VoiceID, &c.Language, &c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel, &c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds, &c.BargeInEnabled, &c.VADSensitivity, &c.TransferEnabled, &c.TransferNumber, &c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } c.BusinessHours = json.RawMessage(businessHours) s.logger.Info().Str("id", id.String()).Msg("Updated voice agent config") return &c, nil } // DeleteConfig deletes a voice agent config func (s *VoiceAgentService) DeleteConfig(ctx context.Context, id uuid.UUID) error { tag, err := s.pool.Exec(ctx, `DELETE FROM voice_agent_configs WHERE id = $1`, id) if err != nil { return fmt.Errorf("delete failed: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("config not found") } s.logger.Info().Str("id", id.String()).Msg("Deleted voice agent config") return nil } // ============================================================================ // Voice Sessions // ============================================================================ // ListSessions lists voice sessions for a specific agent func (s *VoiceAgentService) ListSessions(ctx context.Context, agentID uuid.UUID, params types.ListParams) ([]types.VoiceSession, int64, error) { params = types.DefaultListParams(params) var total int64 if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM voice_sessions WHERE agent_id = $1`, agentID).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, agent_id, caller_number, called_number, asterisk_call_id, agent_session_id, total_turns, stt_provider, tts_provider, stt_audio_seconds, tts_characters, started_at, ended_at, end_reason, metadata, created_at FROM voice_sessions WHERE agent_id = $1 ORDER BY started_at DESC LIMIT $2 OFFSET $3`, agentID, params.Limit, params.Offset) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() sessions := make([]types.VoiceSession, 0) for rows.Next() { var vs types.VoiceSession var metadata []byte if err := rows.Scan( &vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber, &vs.AsteriskCallID, &vs.AgentSessionID, &vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider, &vs.STTAudioSeconds, &vs.TTSCharacters, &vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt, ); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } vs.Metadata = json.RawMessage(metadata) sessions = append(sessions, vs) } return sessions, total, nil } // GetSession gets a voice session by ID, including turns func (s *VoiceAgentService) GetSession(ctx context.Context, sessionID uuid.UUID) (*types.VoiceSession, error) { var vs types.VoiceSession var metadata []byte err := s.pool.QueryRow(ctx, `SELECT id, tenant_id, agent_id, caller_number, called_number, asterisk_call_id, agent_session_id, total_turns, stt_provider, tts_provider, stt_audio_seconds, tts_characters, started_at, ended_at, end_reason, metadata, created_at FROM voice_sessions WHERE id = $1`, sessionID). Scan( &vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber, &vs.AsteriskCallID, &vs.AgentSessionID, &vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider, &vs.STTAudioSeconds, &vs.TTSCharacters, &vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt, ) if err != nil { return nil, fmt.Errorf("session not found: %w", err) } vs.Metadata = json.RawMessage(metadata) // Fetch turns turnRows, err := s.pool.Query(ctx, `SELECT id, session_id, turn_number, role, text, stt_confidence, agent_latency_ms, was_interrupted, created_at FROM voice_session_turns WHERE session_id = $1 ORDER BY turn_number ASC`, sessionID) if err != nil { return nil, fmt.Errorf("turns query failed: %w", err) } defer turnRows.Close() vs.Turns = make([]types.VoiceSessionTurn, 0) for turnRows.Next() { var t types.VoiceSessionTurn if err := turnRows.Scan( &t.ID, &t.SessionID, &t.TurnNumber, &t.Role, &t.Text, &t.STTConfidence, &t.AgentLatencyMs, &t.WasInterrupted, &t.CreatedAt, ); err != nil { return nil, fmt.Errorf("turn scan failed: %w", err) } vs.Turns = append(vs.Turns, t) } return &vs, nil } // joinClauses joins SQL SET clauses with commas func joinClauses(clauses []string) string { result := "" for i, c := range clauses { if i > 0 { result += ", " } result += c } return result }