package state import ( "fmt" "strings" "time" ) const PendingRegionCandidatesMetadataKey = "pending_region_candidates" type Machine struct { session ConversationSession } func NewMachine(initial ConversationSession) *Machine { now := time.Now().UTC() if initial.StartedAt.IsZero() { initial.StartedAt = now } if initial.UpdatedAt.IsZero() { initial.UpdatedAt = initial.StartedAt } if initial.State == "" { initial.State = StateCallStarted } if initial.Region.Status == "" { initial.Region.Status = RegionUnknown } if initial.Metadata == nil { initial.Metadata = map[string]string{} } return &Machine{session: initial} } func (m *Machine) Session() ConversationSession { return cloneSession(m.session) } func (m *Machine) CurrentState() ConversationState { return m.session.State } func (m *Machine) CanTransition(event ConversationEvent) bool { _, err := m.next(event) return err == nil } func (m *Machine) Apply(event ConversationEvent) (TransitionResult, error) { from := m.session.State to, err := m.next(event) if err != nil { m.recordDenied(string(event.Type), err.Error()) return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: requiredActionForState(from)}, err } if event.At.IsZero() { event.At = time.Now().UTC() } if from == StateEnded { return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: "none"}, fmt.Errorf("ended state is immutable") } m.session.State = to switch event.Type { case EventLanguageSelected: m.session.Language = event.Language case EventRegionSelected: m.session.Region = event.Region m.ClearPendingRegion() case EventCallEnded: t := event.At m.session.EndedAt = &t } m.session.UpdatedAt = event.At m.session.TransitionHistory = append(m.session.TransitionHistory, TransitionRecord{From: from, To: to, Event: event.Type, Reason: event.Reason, At: event.At}) return TransitionResult{From: from, To: to, Changed: from != to, MessageKey: messageKeyForState(to), RequiredNextAction: requiredActionForState(to)}, nil } func (m *Machine) AddDenied(action, reason string) { m.recordDenied(action, reason) } func (m *Machine) SetRegionPending(codes []string) { if m.session.Metadata == nil { m.session.Metadata = map[string]string{} } m.session.Region = RegionSelection{Status: RegionPendingClarification, Source: "resolver"} m.session.Metadata[PendingRegionCandidatesMetadataKey] = strings.Join(codes, ",") m.session.UpdatedAt = time.Now().UTC() } func (m *Machine) PendingRegionCandidates() []string { v := strings.TrimSpace(m.session.Metadata[PendingRegionCandidatesMetadataKey]) if v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func (m *Machine) ClearPendingRegion() { if m.session.Metadata != nil { delete(m.session.Metadata, PendingRegionCandidatesMetadataKey) } } func (m *Machine) recordDenied(action, reason string) { m.session.DeniedActions = append(m.session.DeniedActions, DeniedActionRecord{Action: action, State: m.session.State, ReasonCode: reason, At: time.Now().UTC()}) m.session.UpdatedAt = time.Now().UTC() } func (m *Machine) next(event ConversationEvent) (ConversationState, error) { if m.session.State == StateEnded { return StateEnded, fmt.Errorf("ended_state") } switch event.Type { case EventCallStarted: if m.session.State == StateCallStarted { return StateGreeting, nil } case EventGreetingPlayed, EventLanguageRequested: if m.session.State == StateGreeting { return StateReadyToHelp, nil } case EventLanguageSelected: if event.Language != LanguageRU && event.Language != LanguageKK { return m.session.State, fmt.Errorf("invalid_language") } switch m.session.State { case StateLanguageSelection: return StateRegionSelection, nil case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering: return m.session.State, nil } case EventRegionSelected: if event.Region.Code == "" || event.Region.Status != RegionSelected { return m.session.State, fmt.Errorf("invalid_region") } switch m.session.State { case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering: return StateReadyToHelp, nil } case EventQuestionReceived: if m.session.State == StateReadyToHelp { return StateQuestionAnswering, nil } return m.session.State, fmt.Errorf("state_not_ready") case EventAnswerCompleted: if m.session.State == StateQuestionAnswering { return StateReadyToHelp, nil } case EventHandoffRequested: return StateHandoff, nil case EventClosingRequested: return StateClosing, nil case EventCallEnded: return StateEnded, nil } return m.session.State, fmt.Errorf("invalid_transition") } func messageKeyForState(s ConversationState) string { switch s { case StateGreeting: return "greeting.initial" case StateLanguageSelection: return "language.ask" case StateRegionSelection: return "region.ask" case StateReadyToHelp: return "ready.to_help" case StateQuestionAnswering: return "answer.allowed" case StateHandoff: return "handoff.started" case StateClosing: return "closing.started" default: return "" } } func requiredActionForState(s ConversationState) string { switch s { case StateGreeting: return "play_greeting" case StateLanguageSelection: return "ask_language" case StateRegionSelection: return "ask_region" case StateReadyToHelp: return "wait_for_question" case StateQuestionAnswering: return "answer_question" case StateHandoff: return "handoff" case StateClosing, StateEnded: return "close_call" default: return "none" } } func cloneSession(s ConversationSession) ConversationSession { s.TransitionHistory = append([]TransitionRecord(nil), s.TransitionHistory...) s.DeniedActions = append([]DeniedActionRecord(nil), s.DeniedActions...) if s.Metadata != nil { cp := make(map[string]string, len(s.Metadata)) for k, v := range s.Metadata { cp[k] = v } s.Metadata = cp } return s }