sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
package handoff
import (
"strings"
"unicode"
)
type DetectionResult struct {
Requested bool
Confidence float64
MatchedPhrase string
Language string
ReasonCode string
NeedsClarification bool
}
var handoffPhrases = []string{
"соедините с оператором", "переведите на оператора", "хочу оператора", "нужен оператор",
"позовите оператора", "можно с человеком", "хочу поговорить с человеком", "соедините с человеком",
"переключите на оператора", "не хочу робота", "не хочу ии", "позовите живого человека",
"мне нужен специалист", "соедините с консультантом", "живой оператор", "консультант",
"операторға қосыңыз", "операторға қос", "адаммен сөйлескім келеді", "тірі оператор",
"маман керек", "кеңесші керек", "кеңесшімен сөйлесу", "адамға қосыңыз",
"жасанды интеллект керек емес", "роботпен сөйлеспеймін", "оператор керек",
"human operator", "connect me to operator", "transfer to operator", "speak to human", "live agent",
}
var ambiguousPhrases = []string{"оператор связи", "operator schedule", "call center operator schedule"}
func DetectHandoffRequest(text string, language string) DetectionResult {
n := normalize(text)
if n == "" {
return DetectionResult{Language: language, ReasonCode: "empty_input"}
}
for _, p := range ambiguousPhrases {
if strings.Contains(n, p) {
return DetectionResult{Language: language, MatchedPhrase: p, Confidence: 0.45, ReasonCode: "ambiguous", NeedsClarification: true}
}
}
for _, p := range handoffPhrases {
if strings.Contains(n, p) {
return DetectionResult{Requested: true, Confidence: 0.95, MatchedPhrase: p, Language: language, ReasonCode: "exact_phrase"}
}
}
if hasWord(n, "оператор") || hasWord(n, "operator") {
return DetectionResult{Requested: true, Confidence: 0.80, MatchedPhrase: "оператор", Language: language, ReasonCode: "keyword"}
}
return DetectionResult{Language: language, ReasonCode: "no_match"}
}
func normalize(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "ё", "е")
var b strings.Builder
lastSpace := false
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
lastSpace = false
continue
}
if !lastSpace {
b.WriteRune(' ')
lastSpace = true
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func hasWord(text, word string) bool {
for _, f := range strings.Fields(text) {
if f == word {
return true
}
}
return false
}
+24
View File
@@ -0,0 +1,24 @@
package handoff
import "testing"
func TestDetectHandoffRequest(t *testing.T) {
cases := []string{"оператор", "соедините с оператором", "хочу поговорить с человеком", "операторға қосыңыз", "маман керек", "live agent"}
for _, tc := range cases {
res := DetectHandoffRequest(tc, "ru")
if !res.Requested || res.Confidence < 0.7 {
t.Fatalf("%q not detected: %+v", tc, res)
}
}
}
func TestDetectHandoffFalsePositive(t *testing.T) {
res := DetectHandoffRequest("оператор связи тариф", "ru")
if res.Requested || !res.NeedsClarification {
t.Fatalf("expected ambiguous false positive protection: %+v", res)
}
res = DetectHandoffRequest("какой у меня тариф", "ru")
if res.Requested {
t.Fatalf("unexpected handoff: %+v", res)
}
}
+68
View File
@@ -0,0 +1,68 @@
package handoff
import (
"context"
"fmt"
)
type ARIClient interface {
RedirectChannel(ctx context.Context, channelID string, endpoint string) error
ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error
}
type Executor interface {
Execute(ctx context.Context, req HandoffRequest) (HandoffResult, error)
}
type SafeExecutor struct {
ARI ARIClient
}
func (e SafeExecutor) Execute(ctx context.Context, req HandoffRequest) (HandoffResult, error) {
result := HandoffResult{RequestID: req.ID, Mode: req.Mode}
switch req.Mode {
case HandoffModeDisabledStub:
result.Status = HandoffStatusStubbed
result.MessageKey = "handoff.stub"
result.Message = Message(result.MessageKey, req.Language)
return result, nil
case HandoffModeHangupAfterMessage:
result.Status = HandoffStatusCompleted
result.MessageKey = "handoff.stub"
result.Message = Message(result.MessageKey, req.Language)
return result, nil
case HandoffModeARIRedirect:
result.TransferAttempted = true
if e.ARI == nil {
return transferFailed(req, "ari client not configured")
}
if err := e.ARI.RedirectChannel(ctx, req.AsteriskChannelID, req.TargetEndpoint); err != nil {
return transferFailed(req, err.Error())
}
result.Status = HandoffStatusCompleted
result.TransferSucceeded = true
result.MessageKey = "handoff.transfer_started"
result.Message = Message(result.MessageKey, req.Language)
return result, nil
case HandoffModeDialplanContinue:
result.TransferAttempted = true
if e.ARI == nil {
return transferFailed(req, "ari client not configured")
}
if err := e.ARI.ContinueInDialplan(ctx, req.AsteriskChannelID, req.DialplanContext, req.DialplanExtension, req.DialplanPriority); err != nil {
return transferFailed(req, err.Error())
}
result.Status = HandoffStatusCompleted
result.TransferSucceeded = true
result.MessageKey = "handoff.transfer_started"
result.Message = Message(result.MessageKey, req.Language)
return result, nil
default:
return HandoffResult{RequestID: req.ID, Mode: req.Mode, Status: HandoffStatusFailed, MessageKey: "handoff.denied", Message: Message("handoff.denied", req.Language), Error: "unknown handoff mode"}, fmt.Errorf("unknown handoff mode")
}
}
func transferFailed(req HandoffRequest, msg string) (HandoffResult, error) {
result := HandoffResult{RequestID: req.ID, Mode: req.Mode, Status: HandoffStatusFailed, TransferAttempted: true, MessageKey: "handoff.transfer_failed", Message: Message("handoff.transfer_failed", req.Language), Error: SanitizeText(msg, 200)}
return result, fmt.Errorf("handoff transfer failed")
}
+97
View File
@@ -0,0 +1,97 @@
package handoff
import "time"
type FallbackCounters struct {
LanguageFailures int
RegionFailures int
NoAnswerCount int
KBUnavailable int
AIProviderErrors int
MediaErrors int
ToolErrors int
StartedAt time.Time
}
type FallbackConfig struct {
MaxLanguageFailures int
MaxRegionFailures int
MaxNoAnswer int
MaxKBUnavailable int
MaxAIErrors int
MaxMediaErrors int
MaxToolErrors int
CallTimeout time.Duration
}
type FallbackDecision struct {
ShouldOfferHandoff bool
ShouldClose bool
ReasonCode HandoffReasonCode
MessageKey string
Message string
}
type FallbackManager struct {
Config FallbackConfig
}
func NewFallbackManager(cfg FallbackConfig) *FallbackManager {
if cfg.MaxLanguageFailures == 0 {
cfg.MaxLanguageFailures = 3
}
if cfg.MaxRegionFailures == 0 {
cfg.MaxRegionFailures = 3
}
if cfg.MaxNoAnswer == 0 {
cfg.MaxNoAnswer = 2
}
if cfg.MaxKBUnavailable == 0 {
cfg.MaxKBUnavailable = 1
}
if cfg.MaxAIErrors == 0 {
cfg.MaxAIErrors = 1
}
if cfg.MaxMediaErrors == 0 {
cfg.MaxMediaErrors = 1
}
if cfg.MaxToolErrors == 0 {
cfg.MaxToolErrors = 2
}
if cfg.CallTimeout == 0 {
cfg.CallTimeout = 5 * time.Minute
}
return &FallbackManager{Config: cfg}
}
func (m *FallbackManager) Evaluate(c FallbackCounters, language string, now time.Time) FallbackDecision {
if !c.StartedAt.IsZero() && now.Sub(c.StartedAt) >= m.Config.CallTimeout {
return m.decision(true, HandoffReasonTimeout, "fallback.timeout", language)
}
if c.KBUnavailable >= m.Config.MaxKBUnavailable {
return m.decision(false, HandoffReasonKBUnavailable, "fallback.kb_unavailable", language)
}
if c.NoAnswerCount >= m.Config.MaxNoAnswer {
return m.decision(false, HandoffReasonRepeatedNoAnswer, "fallback.no_answer", language)
}
if c.LanguageFailures >= m.Config.MaxLanguageFailures {
return m.decision(false, HandoffReasonLanguageFailures, "fallback.language_failures", language)
}
if c.RegionFailures >= m.Config.MaxRegionFailures {
return m.decision(false, HandoffReasonRegionFailures, "fallback.region_failures", language)
}
if c.AIProviderErrors >= m.Config.MaxAIErrors {
return m.decision(false, HandoffReasonAIProviderError, "fallback.ai_error", language)
}
if c.MediaErrors >= m.Config.MaxMediaErrors {
return m.decision(false, HandoffReasonMediaError, "fallback.media_error", language)
}
if c.ToolErrors >= m.Config.MaxToolErrors {
return m.decision(false, HandoffReasonToolError, "fallback.tool_error", language)
}
return FallbackDecision{}
}
func (m *FallbackManager) decision(close bool, reason HandoffReasonCode, key string, language string) FallbackDecision {
return FallbackDecision{ShouldOfferHandoff: !close, ShouldClose: close, ReasonCode: reason, MessageKey: key, Message: Message(key, language)}
}
+34
View File
@@ -0,0 +1,34 @@
package handoff
import (
"testing"
"time"
)
func TestFallbackManager(t *testing.T) {
m := NewFallbackManager(FallbackConfig{MaxLanguageFailures: 3, MaxRegionFailures: 3, MaxNoAnswer: 2, MaxKBUnavailable: 1, MaxAIErrors: 1, MaxMediaErrors: 1, MaxToolErrors: 2, CallTimeout: time.Minute})
if d := m.Evaluate(FallbackCounters{LanguageFailures: 3}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonLanguageFailures {
t.Fatalf("language fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{RegionFailures: 3}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonRegionFailures {
t.Fatalf("region fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{NoAnswerCount: 2}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonRepeatedNoAnswer {
t.Fatalf("no answer fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{KBUnavailable: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonKBUnavailable {
t.Fatalf("kb fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{AIProviderErrors: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonAIProviderError {
t.Fatalf("ai fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{MediaErrors: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonMediaError {
t.Fatalf("media fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{ToolErrors: 2}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonToolError {
t.Fatalf("tool fallback failed: %+v", d)
}
if d := m.Evaluate(FallbackCounters{StartedAt: time.Now().Add(-2 * time.Minute)}, "ru", time.Now()); !d.ShouldClose || d.ReasonCode != HandoffReasonTimeout {
t.Fatalf("timeout fallback failed: %+v", d)
}
}
+92
View File
@@ -0,0 +1,92 @@
package handoff
import (
"context"
"fmt"
"time"
)
type Manager struct {
Config Config
Store *Store
Executor Executor
}
type RequestInput struct {
CallID string
AsteriskChannelID string
State string
Language string
RegionCode string
Route string
ReasonCode HandoffReasonCode
ReasonText string
Summary string
Metadata map[string]string
}
func NewManager(cfg Config, executor Executor) *Manager {
if cfg.Mode == "" {
cfg = DefaultConfig()
}
if executor == nil {
executor = SafeExecutor{}
}
return &Manager{Config: cfg, Store: NewStore(), Executor: executor}
}
func (m *Manager) Request(ctx context.Context, in RequestInput) (HandoffRequest, HandoffResult, error) {
if existing, ok := m.Store.GetByCall(in.CallID); ok {
result := HandoffResult{RequestID: existing.ID, Status: existing.Status, Mode: existing.Mode, MessageKey: "handoff.already_requested", Message: Message("handoff.already_requested", in.Language)}
return existing, result, nil
}
mode := m.Config.Mode
hasTarget := mode == HandoffModeHangupAfterMessage || mode == HandoffModeDisabledStub ||
(mode == HandoffModeARIRedirect && m.Config.TargetEndpoint != "") ||
(mode == HandoffModeDialplanContinue && m.Config.DialplanContext != "" && m.Config.DialplanExtension != "")
decision := Authorize(PolicyContext{State: in.State, Route: in.Route, HandoffEnabled: m.Config.Enabled, HandoffMode: mode, AllowInTestRouteOnly: m.Config.AllowInTestRouteOnly, HasTarget: hasTarget})
now := time.Now().UTC()
req := HandoffRequest{
ID: fmt.Sprintf("handoff-%d", now.UnixNano()),
CallID: in.CallID,
AsteriskChannelID: in.AsteriskChannelID,
Mode: mode,
Status: HandoffStatusRequested,
ReasonCode: in.ReasonCode,
ReasonText: SanitizeText(in.ReasonText, m.Config.MaxSummaryChars),
Summary: SanitizeText(in.Summary, m.Config.MaxSummaryChars),
Language: in.Language,
RegionCode: in.RegionCode,
Route: in.Route,
TargetEndpoint: m.Config.TargetEndpoint,
DialplanContext: m.Config.DialplanContext,
DialplanExtension: m.Config.DialplanExtension,
DialplanPriority: m.Config.DialplanPriority,
CreatedAt: now,
UpdatedAt: now,
Metadata: in.Metadata,
}
if !decision.Allowed {
req.Status = HandoffStatusFailed
result := HandoffResult{RequestID: req.ID, Status: req.Status, Mode: req.Mode, MessageKey: decision.MessageKey, Message: Message(decision.MessageKey, in.Language), Error: decision.ReasonCode}
m.Store.Put(req)
return req, result, fmt.Errorf(decision.ReasonCode)
}
if decision.StubOnly {
req.Mode = HandoffModeDisabledStub
req.Status = HandoffStatusStubbed
result := HandoffResult{RequestID: req.ID, Status: req.Status, Mode: req.Mode, MessageKey: decision.MessageKey, Message: Message(decision.MessageKey, in.Language)}
m.Store.Put(req)
return req, result, nil
}
result, err := m.Executor.Execute(ctx, req)
req.Status = result.Status
req.Error = result.Error
completed := time.Now().UTC()
if result.Status == HandoffStatusCompleted || result.Status == HandoffStatusFailed || result.Status == HandoffStatusStubbed {
req.CompletedAt = &completed
}
req.UpdatedAt = completed
m.Store.Put(req)
return req, result, err
}
+66
View File
@@ -0,0 +1,66 @@
package handoff
import (
"context"
"testing"
)
type fakeARI struct {
redirects int
continues int
fail bool
}
func (f *fakeARI) RedirectChannel(ctx context.Context, channelID string, endpoint string) error {
f.redirects++
if f.fail {
return context.Canceled
}
return nil
}
func (f *fakeARI) ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error {
f.continues++
if f.fail {
return context.Canceled
}
return nil
}
func TestManagerDisabledStub(t *testing.T) {
ari := &fakeARI{}
m := NewManager(DefaultConfig(), SafeExecutor{ARI: ari})
_, res, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
if err != nil || res.Status != HandoffStatusStubbed || res.TransferAttempted || ari.redirects != 0 || ari.continues != 0 {
t.Fatalf("bad stub result: %+v err=%v ari=%+v", res, err, ari)
}
}
func TestManagerRedirectAndContinue(t *testing.T) {
ari := &fakeARI{}
m := NewManager(Config{Mode: HandoffModeARIRedirect, Enabled: true, TargetEndpoint: "PJSIP/operator", AllowInTestRouteOnly: true, DialplanPriority: 1, MaxSummaryChars: 500}, SafeExecutor{ARI: ari})
_, res, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
if err != nil || !res.TransferSucceeded || ari.redirects != 1 {
t.Fatalf("redirect failed: %+v err=%v ari=%+v", res, err, ari)
}
ari = &fakeARI{}
m = NewManager(Config{Mode: HandoffModeDialplanContinue, Enabled: true, DialplanContext: "ctx", DialplanExtension: "100", DialplanPriority: 1, AllowInTestRouteOnly: true, MaxSummaryChars: 500}, SafeExecutor{ARI: ari})
_, res, err = m.Request(context.Background(), RequestInput{CallID: "c2", AsteriskChannelID: "c2", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
if err != nil || !res.TransferSucceeded || ari.continues != 1 {
t.Fatalf("continue failed: %+v err=%v ari=%+v", res, err, ari)
}
}
func TestManagerSanitizesSummaryAndDeduplicates(t *testing.T) {
m := NewManager(DefaultConfig(), nil)
req, _, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", Summary: "+77011234567 wants operator", ReasonCode: HandoffReasonUserRequested})
if err != nil {
t.Fatal(err)
}
if req.Summary == "+77011234567 wants operator" {
t.Fatalf("summary not sanitized: %q", req.Summary)
}
_, res, _ := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "HANDOFF", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
if res.MessageKey != "handoff.already_requested" {
t.Fatalf("duplicate not idempotent: %+v", res)
}
}
+75
View File
@@ -0,0 +1,75 @@
package handoff
func Message(key, language string) string {
m := map[string]map[string]string{
"handoff.stub": {
"ru": "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.",
"kk": "Операторға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
"": "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.\nОператорға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
},
"handoff.transfer_started": {
"ru": "Соединяю вас с оператором.",
"kk": "Сізді операторға қосып жатырмын.",
},
"handoff.transfer_failed": {
"ru": "Не удалось соединить с оператором. Попробуйте обратиться позже.",
"kk": "Операторға қосу мүмкін болмады. Кейінірек қайталап көріңіз.",
},
"handoff.not_configured": {
"ru": "Перевод на оператора сейчас не настроен.",
"kk": "Операторға аудару қазір бапталмаған.",
},
"handoff.already_requested": {
"ru": "Запрос на оператора уже зафиксирован.",
"kk": "Операторға сұрау тіркелді.",
},
"handoff.denied": {
"ru": "Сейчас перевод на оператора недоступен.",
"kk": "Қазір операторға аудару қолжетімсіз.",
},
"fallback.kb_unavailable": {
"ru": "База знаний временно недоступна. Могу предложить обратиться к оператору.",
"kk": "Білім базасы уақытша қолжетімсіз. Операторға жүгінуді ұсына аламын.",
},
"fallback.no_answer": {
"ru": "В базе знаний нет точной информации по этому вопросу. Могу предложить обратиться к оператору.",
"kk": "Бұл сұрақ бойынша білім базасында нақты ақпарат жоқ. Операторға жүгінуді ұсына аламын.",
},
"fallback.ai_error": {
"ru": "Возникла техническая ошибка AI-оператора. Могу предложить обратиться к оператору.",
"kk": "AI-операторда техникалық қате пайда болды. Операторға жүгінуді ұсына аламын.",
},
"fallback.media_error": {
"ru": "Возникла ошибка аудиосвязи. Могу предложить обратиться к оператору.",
"kk": "Аудио байланысында қате пайда болды. Операторға жүгінуді ұсына аламын.",
},
"fallback.timeout": {
"ru": "Время разговора истекло. Завершаю обращение.",
"kk": "Сөйлесу уақыты аяқталды. Өтінішті аяқтаймын.",
},
"fallback.tool_error": {
"ru": "Не удалось выполнить действие. Могу предложить обратиться к оператору.",
"kk": "Әрекетті орындау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
},
"fallback.language_failures": {
"ru": "Не удалось выбрать язык. Могу предложить обратиться к оператору.",
"kk": "Тілді таңдау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
},
"fallback.region_failures": {
"ru": "Не удалось определить регион. Могу предложить обратиться к оператору.",
"kk": "Аймақты анықтау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
},
}
if byLang, ok := m[key]; ok {
if v := byLang[language]; v != "" {
return v
}
if v := byLang[""]; v != "" {
return v
}
if v := byLang["ru"]; v != "" {
return v
}
}
return key
}
+46
View File
@@ -0,0 +1,46 @@
package handoff
type PolicyContext struct {
State string
Route string
HandoffEnabled bool
HandoffMode HandoffMode
AllowInTestRouteOnly bool
HasTarget bool
AlreadyRequested bool
}
type PolicyDecision struct {
Allowed bool
StubOnly bool
ReasonCode string
MessageKey string
}
func Authorize(ctx PolicyContext) PolicyDecision {
if ctx.State == "ENDED" {
return PolicyDecision{Allowed: false, ReasonCode: "call_ended", MessageKey: "handoff.denied"}
}
if ctx.AlreadyRequested || ctx.State == "HANDOFF" {
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "already_requested", MessageKey: "handoff.already_requested"}
}
if ctx.AllowInTestRouteOnly && ctx.Route != "test" {
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "test_route_only", MessageKey: "handoff.stub"}
}
if !ctx.HandoffEnabled {
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "disabled", MessageKey: "handoff.stub"}
}
switch ctx.HandoffMode {
case HandoffModeDisabledStub:
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "disabled", MessageKey: "handoff.stub"}
case HandoffModeHangupAfterMessage:
return PolicyDecision{Allowed: true, ReasonCode: "ok", MessageKey: "handoff.stub"}
case HandoffModeARIRedirect, HandoffModeDialplanContinue:
if !ctx.HasTarget {
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "not_configured", MessageKey: "handoff.not_configured"}
}
return PolicyDecision{Allowed: true, ReasonCode: "ok", MessageKey: "handoff.transfer_started"}
default:
return PolicyDecision{Allowed: false, ReasonCode: "unknown_mode", MessageKey: "handoff.denied"}
}
}
+22
View File
@@ -0,0 +1,22 @@
package handoff
import "testing"
func TestPolicy(t *testing.T) {
d := Authorize(PolicyContext{State: "READY_TO_HELP", Route: "test", HandoffEnabled: false, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true, HasTarget: true})
if !d.Allowed || !d.StubOnly {
t.Fatalf("disabled must be stub only: %+v", d)
}
d = Authorize(PolicyContext{State: "READY_TO_HELP", Route: "test", HandoffEnabled: true, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true})
if !d.StubOnly || d.ReasonCode != "not_configured" {
t.Fatalf("missing target should stub: %+v", d)
}
d = Authorize(PolicyContext{State: "READY_TO_HELP", Route: "production", HandoffEnabled: true, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true, HasTarget: true})
if !d.StubOnly {
t.Fatalf("production should stub: %+v", d)
}
d = Authorize(PolicyContext{State: "ENDED", Route: "test", HandoffEnabled: true, HandoffMode: HandoffModeDisabledStub})
if d.Allowed {
t.Fatalf("ended should deny: %+v", d)
}
}
+30
View File
@@ -0,0 +1,30 @@
package handoff
import (
"regexp"
"strings"
)
var phoneLike = regexp.MustCompile(`\+?\d[\d\s().-]{5,}\d`)
func SanitizeText(value string, maxRunes int) string {
v := strings.TrimSpace(value)
v = phoneLike.ReplaceAllString(v, "***MASKED_PHONE***")
v = strings.Join(strings.Fields(v), " ")
if maxRunes <= 0 {
maxRunes = 500
}
r := []rune(v)
if len(r) > maxRunes {
return string(r[:maxRunes])
}
return v
}
func SanitizeEndpoint(value string) string {
if strings.Contains(value, "@") {
parts := strings.Split(value, "@")
return "***MASKED***@" + parts[len(parts)-1]
}
return value
}
+47
View File
@@ -0,0 +1,47 @@
package handoff
import "sync"
type Store struct {
mu sync.RWMutex
requests map[string]HandoffRequest
byCall map[string]string
}
func NewStore() *Store {
return &Store{requests: map[string]HandoffRequest{}, byCall: map[string]string{}}
}
func (s *Store) Put(req HandoffRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.requests[req.ID] = req
s.byCall[req.CallID] = req.ID
}
func (s *Store) Get(id string) (HandoffRequest, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
req, ok := s.requests[id]
return req, ok
}
func (s *Store) GetByCall(callID string) (HandoffRequest, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
id, ok := s.byCall[callID]
if !ok {
return HandoffRequest{}, false
}
req, ok := s.requests[id]
return req, ok
}
func (s *Store) DeleteByCall(callID string) {
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.byCall[callID]; ok {
delete(s.requests, id)
delete(s.byCall, callID)
}
}
+91
View File
@@ -0,0 +1,91 @@
package handoff
import "time"
type HandoffMode string
const (
HandoffModeDisabledStub HandoffMode = "disabled_stub"
HandoffModeARIRedirect HandoffMode = "ari_redirect"
HandoffModeDialplanContinue HandoffMode = "dialplan_continue"
HandoffModeHangupAfterMessage HandoffMode = "hangup_after_message"
)
type HandoffStatus string
const (
HandoffStatusRequested HandoffStatus = "requested"
HandoffStatusAccepted HandoffStatus = "accepted"
HandoffStatusStubbed HandoffStatus = "stubbed"
HandoffStatusFailed HandoffStatus = "failed"
HandoffStatusCompleted HandoffStatus = "completed"
HandoffStatusCancelled HandoffStatus = "cancelled"
)
type HandoffReasonCode string
const (
HandoffReasonUserRequested HandoffReasonCode = "user_requested"
HandoffReasonRepeatedNoAnswer HandoffReasonCode = "repeated_no_answer"
HandoffReasonKBUnavailable HandoffReasonCode = "kb_unavailable"
HandoffReasonLanguageFailures HandoffReasonCode = "language_failures"
HandoffReasonRegionFailures HandoffReasonCode = "region_failures"
HandoffReasonAIProviderError HandoffReasonCode = "ai_provider_error"
HandoffReasonMediaError HandoffReasonCode = "media_error"
HandoffReasonToolError HandoffReasonCode = "tool_error"
HandoffReasonTimeout HandoffReasonCode = "timeout"
HandoffReasonSafetyFallback HandoffReasonCode = "safety_fallback"
)
type HandoffRequest struct {
ID string
CallID string
AsteriskChannelID string
Mode HandoffMode
Status HandoffStatus
ReasonCode HandoffReasonCode
ReasonText string
Summary string
Language string
RegionCode string
Route string
TargetEndpoint string
DialplanContext string
DialplanExtension string
DialplanPriority int
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
Error string
Metadata map[string]string
}
type HandoffResult struct {
RequestID string
Status HandoffStatus
Mode HandoffMode
MessageKey string
Message string
TransferAttempted bool
TransferSucceeded bool
Error string
}
type Config struct {
Mode HandoffMode
Enabled bool
TargetEndpoint string
DialplanContext string
DialplanExtension string
DialplanPriority int
Timeout time.Duration
MaxAttempts int
PlayMessageBeforeTransfer bool
HangupAfterStub bool
AllowInTestRouteOnly bool
MaxSummaryChars int
}
func DefaultConfig() Config {
return Config{Mode: HandoffModeDisabledStub, Enabled: false, DialplanPriority: 1, Timeout: 30 * time.Second, MaxAttempts: 1, PlayMessageBeforeTransfer: true, AllowInTestRouteOnly: true, MaxSummaryChars: 500}
}