sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package call
|
||||
|
||||
type Language string
|
||||
|
||||
const (
|
||||
LanguageUnknown Language = ""
|
||||
LanguageRU Language = "ru"
|
||||
LanguageKK Language = "kk"
|
||||
)
|
||||
@@ -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-")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user