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
+200
View File
@@ -0,0 +1,200 @@
package call
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"ai-operator/internal/ai"
"ai-operator/internal/audio"
"ai-operator/internal/media"
)
type MediaClient interface {
Audio() <-chan media.AudioChunk
SendAudio(ctx context.Context, data []byte) error
FlushMedia(ctx context.Context) error
Close(ctx context.Context) error
}
type AudioPump struct {
CallID string
Media MediaClient
Provider ai.VoiceProvider
AsteriskCodec media.Codec
AsteriskSampleRate int
ProviderInputSampleRate int
ProviderOutputSampleRate int
Logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.Mutex
suppressInputUntil time.Time
}
const assistantEchoTailSuppression = 900 * time.Millisecond
func (p *AudioPump) Start(ctx context.Context) error {
if p.Media == nil {
return errors.New("audio pump media client is nil")
}
if p.Provider == nil {
return errors.New("audio pump voice provider is nil")
}
if p.AsteriskSampleRate == 0 {
p.AsteriskSampleRate = 16000
}
if p.AsteriskCodec == "" {
p.AsteriskCodec = media.CodecSLIN16
}
if p.ProviderInputSampleRate == 0 {
p.ProviderInputSampleRate = 24000
}
if p.ProviderOutputSampleRate == 0 {
p.ProviderOutputSampleRate = 24000
}
p.ctx, p.cancel = context.WithCancel(ctx)
p.wg.Add(1)
go p.asteriskToProvider()
return nil
}
func (p *AudioPump) Stop(ctx context.Context) error {
if p.cancel != nil {
p.cancel()
}
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (p *AudioPump) HandleProviderEvent(ctx context.Context, event ai.VoiceEvent) error {
switch event.Type {
case ai.VoiceEventAssistantAudioDelta:
if len(event.Audio) == 0 {
return nil
}
pcm, err := audio.ResamplePCM16MonoLinear(event.Audio, p.ProviderOutputSampleRate, p.AsteriskSampleRate)
if err != nil {
return err
}
p.suppressInputFor(pcmDuration(pcm, p.AsteriskSampleRate) + assistantEchoTailSuppression)
out := p.encodeForAsterisk(pcm)
if err := p.Media.SendAudio(ctx, out); err != nil {
return err
}
p.log("assistant audio forwarded", "channel_id", p.CallID, "bytes", len(out))
case ai.VoiceEventAssistantAudioDone:
p.suppressInputFor(assistantEchoTailSuppression)
case ai.VoiceEventInterruption:
if p.inputSuppressed() {
p.log("ignored interruption during assistant playback", "channel_id", p.CallID)
return nil
}
if err := p.Media.FlushMedia(ctx); err != nil {
return err
}
p.log("media flushed on interruption", "channel_id", p.CallID)
}
return nil
}
func (p *AudioPump) asteriskToProvider() {
defer p.wg.Done()
for {
select {
case <-p.ctx.Done():
return
case chunk, ok := <-p.Media.Audio():
if !ok {
return
}
if len(chunk.Data) == 0 {
continue
}
if p.inputSuppressed() {
continue
}
pcm := p.decodeFromAsterisk(chunk)
in, err := audio.ResamplePCM16MonoLinear(pcm, p.AsteriskSampleRate, p.ProviderInputSampleRate)
if err != nil {
p.log("audio pump input resample failed", "channel_id", p.CallID, "error", err)
continue
}
sendCtx, cancel := context.WithTimeout(p.ctx, 2*time.Second)
err = p.Provider.SendAudio(sendCtx, media.AudioChunk{CallID: p.CallID, Data: in, Codec: media.CodecSLIN16, Timestamp: chunk.Timestamp})
cancel()
if err != nil {
p.log("audio pump provider send failed", "channel_id", p.CallID, "error", err)
return
}
}
}
}
func (p *AudioPump) suppressInputFor(d time.Duration) {
if d <= 0 {
return
}
until := time.Now().Add(d)
p.mu.Lock()
if until.After(p.suppressInputUntil) {
p.suppressInputUntil = until
}
p.mu.Unlock()
}
func (p *AudioPump) inputSuppressed() bool {
p.mu.Lock()
defer p.mu.Unlock()
return time.Now().Before(p.suppressInputUntil)
}
func pcmDuration(pcm []byte, sampleRate int) time.Duration {
if sampleRate <= 0 || len(pcm) == 0 {
return 0
}
samples := len(pcm) / 2
return time.Duration(samples) * time.Second / time.Duration(sampleRate)
}
func (p *AudioPump) decodeFromAsterisk(chunk media.AudioChunk) []byte {
codec := chunk.Codec
if codec == "" {
codec = p.AsteriskCodec
}
switch codec {
case media.CodecULaw:
return audio.DecodeULaw(chunk.Data)
default:
return chunk.Data
}
}
func (p *AudioPump) encodeForAsterisk(pcm []byte) []byte {
switch p.AsteriskCodec {
case media.CodecULaw:
return audio.EncodeULaw(pcm)
default:
return pcm
}
}
func (p *AudioPump) log(msg string, args ...any) {
if p.Logger != nil {
p.Logger.Info(msg, args...)
}
}
+133
View File
@@ -0,0 +1,133 @@
package call
import (
"context"
"testing"
"time"
"ai-operator/internal/ai"
"ai-operator/internal/media"
)
type pumpMedia struct {
audio chan media.AudioChunk
sent [][]byte
flushed int
}
func newPumpMedia() *pumpMedia {
return &pumpMedia{audio: make(chan media.AudioChunk, 4)}
}
func (m *pumpMedia) Audio() <-chan media.AudioChunk { return m.audio }
func (m *pumpMedia) SendAudio(ctx context.Context, data []byte) error {
m.sent = append(m.sent, append([]byte(nil), data...))
return nil
}
func (m *pumpMedia) FlushMedia(ctx context.Context) error { m.flushed++; return nil }
func (m *pumpMedia) Close(ctx context.Context) error { close(m.audio); return nil }
type pumpProvider struct {
sent []media.AudioChunk
}
func (p *pumpProvider) StartSession(ctx context.Context, config ai.VoiceSessionConfig) error {
return nil
}
func (p *pumpProvider) SendAudio(ctx context.Context, chunk media.AudioChunk) error {
p.sent = append(p.sent, media.AudioChunk{CallID: chunk.CallID, Data: append([]byte(nil), chunk.Data...), Codec: chunk.Codec, Timestamp: chunk.Timestamp})
return nil
}
func (p *pumpProvider) SendToolResult(ctx context.Context, result ai.ToolResult) error { return nil }
func (p *pumpProvider) Close(ctx context.Context) error { return nil }
func (p *pumpProvider) Events() <-chan ai.VoiceEvent { return nil }
func (p *pumpProvider) Stats() ai.VoiceProviderStats { return ai.VoiceProviderStats{} }
func TestAudioPumpForwardsAsteriskAudioToProvider(t *testing.T) {
pm := newPumpMedia()
pp := &pumpProvider{}
pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := pump.Start(ctx); err != nil {
t.Fatal(err)
}
pm.audio <- media.AudioChunk{Data: []byte{0, 0, 1, 0, 2, 0, 3, 0}, Codec: media.CodecSLIN16, Timestamp: time.Now()}
deadline := time.Now().Add(time.Second)
for len(pp.sent) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if len(pp.sent) != 1 {
t.Fatalf("provider chunks=%d", len(pp.sent))
}
if len(pp.sent[0].Data) <= 8 {
t.Fatalf("expected 16k->24k resample to increase bytes, got %d", len(pp.sent[0].Data))
}
}
func TestAudioPumpForwardsProviderAudioToAsterisk(t *testing.T) {
pm := newPumpMedia()
pp := &pumpProvider{}
pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000}
in := []byte{0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0}
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: in}); err != nil {
t.Fatal(err)
}
if len(pm.sent) != 1 {
t.Fatalf("media sent chunks=%d", len(pm.sent))
}
if len(pm.sent[0]) >= len(in) {
t.Fatalf("expected 24k->16k resample to reduce bytes, got %d from %d", len(pm.sent[0]), len(in))
}
pump.mu.Lock()
pump.suppressInputUntil = time.Now().Add(-time.Second)
pump.mu.Unlock()
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil {
t.Fatal(err)
}
if pm.flushed != 1 {
t.Fatalf("flush count=%d", pm.flushed)
}
}
func TestAudioPumpSuppressesInputDuringAssistantPlayback(t *testing.T) {
pm := newPumpMedia()
pp := &pumpProvider{}
pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := pump.Start(ctx); err != nil {
t.Fatal(err)
}
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: []byte{0, 0, 1, 0, 2, 0, 3, 0}}); err != nil {
t.Fatal(err)
}
pm.audio <- media.AudioChunk{Data: []byte{0, 0, 1, 0, 2, 0, 3, 0}, Codec: media.CodecSLIN16, Timestamp: time.Now()}
time.Sleep(50 * time.Millisecond)
if len(pp.sent) != 0 {
t.Fatalf("provider received echo audio chunks=%d", len(pp.sent))
}
}
func TestAudioPumpIgnoresEchoInterruptionDuringAssistantPlayback(t *testing.T) {
pm := newPumpMedia()
pp := &pumpProvider{}
pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000}
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: []byte{0, 0, 1, 0, 2, 0, 3, 0}}); err != nil {
t.Fatal(err)
}
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil {
t.Fatal(err)
}
if pm.flushed != 0 {
t.Fatalf("unexpected flush during assistant echo suppression: %d", pm.flushed)
}
pump.mu.Lock()
pump.suppressInputUntil = time.Now().Add(-time.Second)
pump.mu.Unlock()
if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil {
t.Fatal(err)
}
if pm.flushed != 1 {
t.Fatalf("flush count=%d", pm.flushed)
}
}
+9
View File
@@ -0,0 +1,9 @@
package call
type Language string
const (
LanguageUnknown Language = ""
LanguageRU Language = "ru"
LanguageKK Language = "kk"
)
+348
View File
@@ -0,0 +1,348 @@
package call
import (
"context"
"log/slog"
"slices"
"strings"
"time"
"ai-operator/internal/ai"
"ai-operator/internal/asterisk/ari"
"ai-operator/internal/config"
"ai-operator/internal/media"
"ai-operator/internal/media/asteriskws"
)
type ManagerMode string
const (
ManagerModeObserveOnly ManagerMode = "observe_only"
ManagerModeCallControl ManagerMode = "call_control"
)
type ManagerConfig struct {
Mode ManagerMode
AllowedStasisArgs []string
TestCallHangupAfter time.Duration
ProductionEnabled bool
MediaEnabled bool
MediaTestMode string
MediaCodec media.Codec
VoiceInputSampleRate int
VoiceOutputSampleRate int
MediaStarter MediaStarter
VoiceProvider ai.VoiceProvider
StartDialogue func(context.Context, *CallSession) (string, error)
HandleVoiceEvent func(context.Context, string, ai.VoiceEvent) (*ai.ToolResult, error)
EndDialogue func(context.Context, string, string) error
}
type MediaStarter interface {
StartCallMedia(ctx context.Context, session *CallSession) (MediaClient, error)
}
type diagnosticMediaClient interface {
GetStatus(ctx context.Context) error
Events() <-chan asteriskws.ControlEvent
Stats() media.Stats
}
type playbackActionClient interface {
PlayChannel(ctx context.Context, channelID string, media string) error
}
type Manager struct {
cfg ManagerConfig
actions ari.ActionClient
store *SessionStore
logger *slog.Logger
}
func DefaultManagerConfig(mode ManagerMode, hangupAfter time.Duration) ManagerConfig {
if hangupAfter == 0 {
hangupAfter = 5 * time.Second
}
return ManagerConfig{Mode: mode, AllowedStasisArgs: []string{"test"}, TestCallHangupAfter: hangupAfter, ProductionEnabled: false}
}
func NewManager(cfg ManagerConfig, actions ari.ActionClient, store *SessionStore, logger *slog.Logger) *Manager {
if store == nil {
store = NewSessionStore()
}
return &Manager{cfg: cfg, actions: actions, store: store, logger: logger}
}
func (m *Manager) Store() *SessionStore { return m.store }
func (m *Manager) HandleEvent(ctx context.Context, event ari.Event) error {
switch e := event.(type) {
case ari.StasisStartEvent:
return m.handleStasisStart(ctx, e)
case ari.StasisEndEvent:
m.endSession(e.Channel.ID, "stasis_end")
case ari.ChannelHangupRequestEvent:
m.endSession(e.Channel.ID, "hangup_request")
case ari.ChannelDestroyedEvent:
m.endSession(e.Channel.ID, "channel_destroyed")
case ari.ChannelStateChangeEvent:
m.log("channel state changed", "channel_id", e.Channel.ID, "state", e.Channel.State)
}
return nil
}
func (m *Manager) handleStasisStart(ctx context.Context, e ari.StasisStartEvent) error {
if isGeneratedMediaChannel(e.Channel.ID) || isGeneratedMediaChannel(e.Channel.Name) {
m.log("media channel stasis start ignored", "channel_id", e.Channel.ID)
return nil
}
route := detectRoute(e.Args)
session := &CallSession{CallID: e.Channel.ID, AsteriskChannelID: e.Channel.ID, CallerNumber: e.Channel.Caller.Number, CallerName: e.Channel.Caller.Name, State: StateCallStarted, Language: LanguageUnknown, StasisArgs: e.Args, Route: route, StartedAt: time.Now().UTC()}
m.store.Create(session)
m.log("stasis start", "channel_id", e.Channel.ID, "caller", config.MaskPhoneNumber(e.Channel.Caller.Number), "route", route, "mode", string(m.cfg.Mode))
if m.cfg.Mode == ManagerModeObserveOnly {
m.log("observe-only, no channel control", "channel_id", e.Channel.ID)
return nil
}
if m.cfg.Mode != ManagerModeCallControl {
return nil
}
if route != "test" || !slices.Contains(m.cfg.AllowedStasisArgs, "test") {
m.log("non-test route rejected", "channel_id", e.Channel.ID, "route", route)
if m.actions != nil {
return m.actions.HangupChannel(ctx, e.Channel.ID)
}
return nil
}
systemPrompt := ""
if m.cfg.StartDialogue != nil {
prompt, err := m.cfg.StartDialogue(ctx, session)
if err != nil {
return err
}
systemPrompt = prompt
}
if m.cfg.VoiceProvider != nil {
if err := m.cfg.VoiceProvider.StartSession(ctx, ai.VoiceSessionConfig{CallID: session.CallID, SystemPrompt: systemPrompt, InputAudioFormat: "pcm16", OutputAudioFormat: "pcm16", InputSampleRate: m.voiceInputSampleRate(), OutputSampleRate: m.voiceOutputSampleRate()}); err != nil {
return err
}
}
if m.actions != nil {
if err := m.actions.AnswerChannel(ctx, e.Channel.ID); err != nil {
return err
}
session.Answered = true
if m.cfg.MediaTestMode == "playback" {
m.playDiagnostic(ctx, e.Channel.ID)
}
var pump *AudioPump
if m.cfg.MediaEnabled && m.cfg.MediaStarter != nil {
mediaCtx, mediaCancel := context.WithCancel(context.Background())
session.MediaCancel = mediaCancel
mediaClient, err := m.cfg.MediaStarter.StartCallMedia(mediaCtx, session)
if err != nil {
mediaCancel()
return err
}
if mediaClient != nil && m.cfg.VoiceProvider != nil {
pump = &AudioPump{CallID: session.CallID, Media: mediaClient, Provider: m.cfg.VoiceProvider, AsteriskCodec: m.mediaCodec(), AsteriskSampleRate: m.mediaSampleRate(), ProviderInputSampleRate: m.voiceInputSampleRate(), ProviderOutputSampleRate: m.voiceOutputSampleRate(), Logger: m.logger}
if err := pump.Start(mediaCtx); err != nil {
mediaCancel()
return err
}
}
if mediaClient != nil && m.cfg.MediaTestMode == "tone" {
m.startMediaDiagnostics(mediaCtx, session.CallID, mediaClient)
m.startTonePump(mediaCtx, session.CallID, mediaClient)
}
}
if m.cfg.VoiceProvider != nil {
m.startVoiceEventPump(session.CallID, pump)
}
go m.delayedHangup(e.Channel.ID)
}
return nil
}
func (m *Manager) startVoiceEventPump(callID string, pump *AudioPump) {
if m.cfg.VoiceProvider == nil || m.cfg.HandleVoiceEvent == nil {
return
}
go func() {
for event := range m.cfg.VoiceProvider.Events() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if pump != nil {
if err := pump.HandleProviderEvent(ctx, event); err != nil {
m.log("audio pump provider event failed", "channel_id", callID, "event_type", event.Type, "error", err)
}
}
result, err := m.cfg.HandleVoiceEvent(ctx, callID, event)
if err != nil {
m.log("voice event handling failed", "channel_id", callID, "event_type", event.Type, "error", err)
}
if result != nil {
if err := m.cfg.VoiceProvider.SendToolResult(ctx, *result); err != nil {
m.log("voice tool result send failed", "channel_id", callID, "error", err)
}
}
cancel()
}
if pump != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = pump.Stop(ctx)
cancel()
}
}()
}
func (m *Manager) startTonePump(ctx context.Context, callID string, mediaClient MediaClient) {
go func() {
codec := m.mediaCodec()
sampleRate := m.mediaSampleRate()
payload := asteriskws.GenerateSineTone(codec, 440, 100*time.Millisecond, sampleRate, 0.08)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
sendCtx, cancel := context.WithTimeout(ctx, time.Second)
err := mediaClient.SendAudio(sendCtx, payload)
cancel()
if err != nil {
m.log("live tone send failed", "channel_id", callID, "error", err)
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
m.log("live tone pump started", "channel_id", callID, "codec", string(m.mediaCodec()), "sample_rate", m.mediaSampleRate())
}
func (m *Manager) playDiagnostic(ctx context.Context, channelID string) {
player, ok := m.actions.(playbackActionClient)
if !ok {
m.log("diagnostic playback unavailable", "channel_id", channelID)
return
}
playCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := player.PlayChannel(playCtx, channelID, "sound:hello-world"); err != nil {
m.log("diagnostic playback failed", "channel_id", channelID, "error", err)
return
}
m.log("diagnostic playback started", "channel_id", channelID, "media", "sound:hello-world")
}
func (m *Manager) startMediaDiagnostics(ctx context.Context, callID string, mediaClient MediaClient) {
diag, ok := mediaClient.(diagnosticMediaClient)
if !ok {
return
}
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-diag.Events():
if !ok {
return
}
switch ev.Type {
case asteriskws.ControlStatus, asteriskws.ControlMediaXOFF, asteriskws.ControlMediaXON, asteriskws.ControlQueueDrained:
m.log("media websocket control", "channel_id", callID, "event", string(ev.Type), "raw", ev.Raw)
}
case <-ticker.C:
statusCtx, cancel := context.WithTimeout(ctx, time.Second)
_ = diag.GetStatus(statusCtx)
cancel()
st := diag.Stats()
m.log("media websocket stats", "channel_id", callID, "inbound_frames", st.InboundFrames, "inbound_bytes", st.InboundBytes, "outbound_frames", st.OutboundFrames, "outbound_bytes", st.OutboundBytes, "xoff", st.XOFFCount, "xon", st.XONCount)
}
}
}()
}
func (m *Manager) delayedHangup(channelID string) {
time.Sleep(m.cfg.TestCallHangupAfter)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if m.actions != nil {
_ = m.actions.HangupChannel(ctx, channelID)
}
}
func (m *Manager) mediaCodec() media.Codec {
if m.cfg.MediaCodec != "" {
return m.cfg.MediaCodec
}
return media.CodecSLIN16
}
func (m *Manager) mediaSampleRate() int {
switch m.mediaCodec() {
case media.CodecULaw, media.CodecALaw:
return 8000
default:
return 16000
}
}
func (m *Manager) voiceInputSampleRate() int {
if m.cfg.VoiceInputSampleRate > 0 {
return m.cfg.VoiceInputSampleRate
}
return 24000
}
func (m *Manager) voiceOutputSampleRate() int {
if m.cfg.VoiceOutputSampleRate > 0 {
return m.cfg.VoiceOutputSampleRate
}
return 24000
}
func (m *Manager) endSession(channelID, reason string) {
session, ok := m.store.Get(channelID)
if !ok {
return
}
now := time.Now().UTC()
session.EndedAt = &now
session.State = StateEnded
duration := now.Sub(session.StartedAt).String()
if session.MediaCancel != nil {
session.MediaCancel()
}
if m.cfg.VoiceProvider != nil {
_ = m.cfg.VoiceProvider.Close(context.Background())
}
if m.cfg.EndDialogue != nil {
_ = m.cfg.EndDialogue(context.Background(), channelID, reason)
}
m.store.Delete(channelID)
m.log("session ended", "channel_id", channelID, "route", session.Route, "answered", session.Answered, "duration", duration, "reason", reason)
}
func detectRoute(args []string) string {
if slices.Contains(args, "test") {
return "test"
}
if slices.Contains(args, "production") {
return "production"
}
return "unknown"
}
func (m *Manager) log(msg string, args ...any) {
if m.logger != nil {
m.logger.Info(msg, args...)
}
}
func isGeneratedMediaChannel(value string) bool {
return strings.Contains(value, "aiop-media-") || strings.Contains(value, "aiop-selftest-")
}
+173
View File
@@ -0,0 +1,173 @@
package call
import (
"context"
"testing"
"time"
"ai-operator/internal/asterisk/ari"
)
type fakeActions struct{ answered, hungup, played []string }
func (f *fakeActions) AnswerChannel(ctx context.Context, channelID string) error {
f.answered = append(f.answered, channelID)
return nil
}
func (f *fakeActions) HangupChannel(ctx context.Context, channelID string) error {
f.hungup = append(f.hungup, channelID)
return nil
}
func (f *fakeActions) GetChannel(ctx context.Context, channelID string) (*ari.ARIChannel, error) {
return &ari.ARIChannel{ID: channelID}, nil
}
func (f *fakeActions) PlayChannel(ctx context.Context, channelID string, media string) error {
f.played = append(f.played, channelID+" "+media)
return nil
}
func startEvent(id string, args []string) ari.StasisStartEvent {
return ari.StasisStartEvent{BaseEvent: ari.BaseEvent{Type: ari.EventStasisStart}, Args: args, Channel: ari.ARIChannel{ID: id, Caller: ari.ARICallerID{Number: "+77771234567"}}}
}
func TestManagerObserveOnly(t *testing.T) {
fake := &fakeActions{}
store := NewSessionStore()
m := NewManager(DefaultManagerConfig(ManagerModeObserveOnly, time.Millisecond), fake, store, nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if len(fake.answered) != 0 || len(fake.hungup) != 0 {
t.Fatal("observe-only controlled channel")
}
if store.Count() != 1 {
t.Fatalf("count=%d", store.Count())
}
}
func TestManagerCallControlTestRoute(t *testing.T) {
fake := &fakeActions{}
m := NewManager(DefaultManagerConfig(ManagerModeCallControl, 5*time.Millisecond), fake, NewSessionStore(), nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if len(fake.answered) != 1 {
t.Fatalf("answered=%v", fake.answered)
}
time.Sleep(20 * time.Millisecond)
if len(fake.hungup) != 1 {
t.Fatalf("hungup=%v", fake.hungup)
}
}
func TestManagerPlaybackDiagnostic(t *testing.T) {
fake := &fakeActions{}
cfg := DefaultManagerConfig(ManagerModeCallControl, 5*time.Millisecond)
cfg.MediaTestMode = "playback"
m := NewManager(cfg, fake, NewSessionStore(), nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if len(fake.answered) != 1 {
t.Fatalf("answered=%v", fake.answered)
}
if len(fake.played) != 1 || fake.played[0] != "c1 sound:hello-world" {
t.Fatalf("played=%v", fake.played)
}
}
func TestManagerRejectsProductionAndCleans(t *testing.T) {
fake := &fakeActions{}
store := NewSessionStore()
m := NewManager(DefaultManagerConfig(ManagerModeCallControl, time.Millisecond), fake, store, nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"production"})); err != nil {
t.Fatal(err)
}
if len(fake.answered) != 0 || len(fake.hungup) != 1 {
t.Fatalf("answered=%v hungup=%v", fake.answered, fake.hungup)
}
_ = m.HandleEvent(context.Background(), ari.StasisEndEvent{BaseEvent: ari.BaseEvent{Type: ari.EventStasisEnd}, Channel: ari.ARIChannel{ID: "c1"}})
if store.Count() != 0 {
t.Fatalf("count=%d", store.Count())
}
_ = m.HandleEvent(context.Background(), startEvent("c2", []string{"test"}))
_ = m.HandleEvent(context.Background(), ari.ChannelDestroyedEvent{BaseEvent: ari.BaseEvent{Type: ari.EventChannelDestroyed}, Channel: ari.ARIChannel{ID: "c2"}})
if store.Count() != 0 {
t.Fatalf("count=%d", store.Count())
}
}
type fakeMediaStarter struct{ calls int }
func (f *fakeMediaStarter) StartCallMedia(ctx context.Context, session *CallSession) (MediaClient, error) {
f.calls++
session.MediaConnected = true
return nil, nil
}
func TestManagerMediaIntegrationAndMediaChannelIgnored(t *testing.T) {
fake := &fakeActions{}
media := &fakeMediaStarter{}
cfg := DefaultManagerConfig(ManagerModeCallControl, time.Millisecond)
cfg.MediaEnabled = true
cfg.MediaStarter = media
m := NewManager(cfg, fake, NewSessionStore(), nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if media.calls != 1 {
t.Fatalf("media calls=%d", media.calls)
}
if err := m.HandleEvent(context.Background(), startEvent("aiop-media-c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if m.Store().Count() != 1 {
t.Fatalf("media channel treated as caller, count=%d", m.Store().Count())
}
}
func TestManagerDialogueIntegration(t *testing.T) {
fake := &fakeActions{}
started := false
ended := false
cfg := DefaultManagerConfig(ManagerModeCallControl, time.Millisecond)
cfg.StartDialogue = func(ctx context.Context, session *CallSession) (string, error) {
started = true
if session.Route != "test" {
t.Fatalf("route=%s", session.Route)
}
return "state prompt", nil
}
cfg.EndDialogue = func(ctx context.Context, callID string, reason string) error {
ended = true
return nil
}
m := NewManager(cfg, fake, NewSessionStore(), nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"test"})); err != nil {
t.Fatal(err)
}
if !started {
t.Fatal("dialogue not started")
}
_ = m.HandleEvent(context.Background(), ari.StasisEndEvent{BaseEvent: ari.BaseEvent{Type: ari.EventStasisEnd}, Channel: ari.ARIChannel{ID: "c1"}})
if !ended {
t.Fatal("dialogue not ended")
}
}
func TestManagerDoesNotStartDialogueForProduction(t *testing.T) {
fake := &fakeActions{}
started := false
cfg := DefaultManagerConfig(ManagerModeCallControl, time.Millisecond)
cfg.StartDialogue = func(ctx context.Context, session *CallSession) (string, error) {
started = true
return "", nil
}
m := NewManager(cfg, fake, NewSessionStore(), nil)
if err := m.HandleEvent(context.Background(), startEvent("c1", []string{"production"})); err != nil {
t.Fatal(err)
}
if started {
t.Fatal("dialogue started for production route")
}
}
+33
View File
@@ -0,0 +1,33 @@
package call
import (
"context"
"ai-operator/internal/media"
"time"
)
type CallSession struct {
CallID string
AsteriskChannelID string
CallerNumber string
CallerName string
State CallState
Language Language
RegionCode string
StasisArgs []string
Route string
Answered bool
BridgeID string
MediaChannelID string
MediaConnectionID string
MediaConnected bool
MediaCancel context.CancelFunc
MediaStats media.Stats
StartedAt time.Time
EndedAt *time.Time
}
func NewSession(callID, channelID, callerNumber string, startedAt time.Time) CallSession {
return CallSession{CallID: callID, AsteriskChannelID: channelID, CallerNumber: callerNumber, State: StateCallStarted, Language: LanguageUnknown, StartedAt: startedAt}
}
+41
View File
@@ -0,0 +1,41 @@
package call
import "sync"
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*CallSession
}
func NewSessionStore() *SessionStore { return &SessionStore{sessions: make(map[string]*CallSession)} }
func (s *SessionStore) Create(session *CallSession) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.AsteriskChannelID] = session
}
func (s *SessionStore) Get(channelID string) (*CallSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[channelID]
return session, ok
}
func (s *SessionStore) Delete(channelID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, channelID)
}
func (s *SessionStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.sessions)
}
func (s *SessionStore) List() []*CallSession {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*CallSession, 0, len(s.sessions))
for _, session := range s.sessions {
out = append(out, session)
}
return out
}
+42
View File
@@ -0,0 +1,42 @@
package call
import (
"fmt"
"sync"
"testing"
"time"
)
func TestSessionStoreCreateGetDelete(t *testing.T) {
store := NewSessionStore()
store.Create(&CallSession{AsteriskChannelID: "c1"})
if store.Count() != 1 {
t.Fatalf("count=%d", store.Count())
}
if _, ok := store.Get("c1"); !ok {
t.Fatal("missing c1")
}
store.Delete("missing")
store.Delete("c1")
if store.Count() != 0 {
t.Fatalf("count=%d", store.Count())
}
}
func TestSessionStoreConcurrent(t *testing.T) {
store := NewSessionStore()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
id := fmt.Sprintf("c%d", i)
store.Create(&CallSession{AsteriskChannelID: id, StartedAt: time.Now()})
_, _ = store.Get(id)
}(i)
}
wg.Wait()
if store.Count() != 50 {
t.Fatalf("count=%d", store.Count())
}
}
+22
View File
@@ -0,0 +1,22 @@
package call
import (
"testing"
"time"
)
func TestLanguageConstants(t *testing.T) {
if LanguageRU != "ru" {
t.Fatalf("LanguageRU = %q", LanguageRU)
}
if LanguageKK != "kk" {
t.Fatalf("LanguageKK = %q", LanguageKK)
}
}
func TestInitialState(t *testing.T) {
session := NewSession("call-1", "channel-1", "+7000", time.Now())
if session.State != StateCallStarted {
t.Fatalf("initial state = %q, want %q", session.State, StateCallStarted)
}
}
+15
View File
@@ -0,0 +1,15 @@
package call
type CallState string
const (
StateCallStarted CallState = "CALL_STARTED"
StateGreeting CallState = "GREETING"
StateLanguageSelection CallState = "LANGUAGE_SELECTION"
StateRegionSelection CallState = "REGION_SELECTION"
StateReadyToHelp CallState = "READY_TO_HELP"
StateQuestionAnswering CallState = "QUESTION_ANSWERING"
StateHandoff CallState = "HANDOFF"
StateClosing CallState = "CLOSING"
StateEnded CallState = "ENDED"
)