216 lines
6.0 KiB
Go
216 lines
6.0 KiB
Go
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
|
|
}
|