package gateway import ( "context" "fmt" "log/slog" "strings" "time" "ai-operator/internal/asterisk/ari" "ai-operator/internal/call" "ai-operator/internal/config" "ai-operator/internal/media" "ai-operator/internal/media/asteriskws" ) type TestMode string const ( TestModeStats TestMode = "stats" TestModeSilence TestMode = "silence" TestModeTone TestMode = "tone" TestModeEcho TestMode = "echo" ) type SelfTestConfig struct { Duration time.Duration Mode TestMode } type SelfTestResult struct { OK bool ExternalMediaChannelCreated bool ConnectionIDRetrieved bool MediaWebSocketConnected bool MediaStartReceived bool Format string OptimalFrameSize int PTimeMS int GetStatusSent bool TestPayloadSent bool CleanupOK bool Error string Stats media.Stats } type Gateway struct { cfg config.Config actions ari.MediaActionClient logger *slog.Logger } func New(cfg config.Config, actions ari.MediaActionClient, logger *slog.Logger) *Gateway { return &Gateway{cfg: cfg, actions: actions, logger: logger} } func (g *Gateway) SelfTest(ctx context.Context, cfg SelfTestConfig) (res SelfTestResult) { if cfg.Duration == 0 { cfg.Duration = 3 * time.Second } if cfg.Mode == "" { cfg.Mode = TestModeSilence } id := shortID("aiop-selftest") bridgeID := "aiop-bridge-" + id _ = g.actions.CreateBridge(ctx, bridgeID, "mixing,dtmf_events") defer g.actions.DeleteBridge(context.Background(), bridgeID) codec := g.mediaCodec() ch, err := g.actions.CreateExternalMediaChannel(ctx, ari.ExternalMediaRequest{ChannelID: id, App: g.cfg.Asterisk.ARIApp, ExternalHost: "INCOMING", Encapsulation: "none", Transport: "websocket", ConnectionType: "server", Format: string(codec), Direction: "both", Data: "selftest"}) if err != nil { res.Error = err.Error() return res } res.ExternalMediaChannelCreated = true channelID := ch.ID if channelID == "" { channelID = id } defer func() { cleanupErr := g.actions.HangupChannel(context.Background(), channelID) res.CleanupOK = cleanupErr == nil if cleanupErr != nil && res.Error == "" { res.Error = cleanupErr.Error() } }() connID, err := g.waitConnectionID(ctx, channelID) if err != nil { res.Error = err.Error() return res } res.ConnectionIDRetrieved = true _ = g.actions.AddChannelToBridge(ctx, bridgeID, channelID) url, _, err := asteriskws.BuildMediaWebSocketURL(g.cfg.Asterisk.MediaWSBaseURL, connID) if err != nil { res.Error = err.Error() return res } client := asteriskws.NewClient(asteriskws.ClientConfig{URL: url, ConnectionID: connID, Codec: codec, BasicAuthUser: g.cfg.Asterisk.ARIUser, BasicAuthPassword: g.cfg.Asterisk.ARIPassword}) if err := client.Connect(ctx); err != nil { res.Error = err.Error() return res } defer client.Close(context.Background()) res.MediaWebSocketConnected = true if err := client.GetStatus(ctx); err == nil { res.GetStatusSent = true } deadline := time.After(5 * time.Second) for !res.MediaStartReceived { select { case ev := <-client.Events(): if ev.Type == asteriskws.ControlMediaStart { ms := ev.MediaStart() if ms != nil { res.MediaStartReceived = true res.Format = ms.Format res.OptimalFrameSize = ms.OptimalFrameSize res.PTimeMS = ms.PTimeMS } } case <-deadline: res.Error = "timeout waiting for MEDIA_START" return res case <-ctx.Done(): res.Error = ctx.Err().Error() return res } } _ = client.StartMediaBuffering(ctx) payload := asteriskws.GenerateSilence(codec, 100*time.Millisecond, g.mediaSampleRate()) if cfg.Mode == TestModeTone { payload = asteriskws.GenerateSineTone(codec, 440, 100*time.Millisecond, g.mediaSampleRate(), 0.05) } if len(payload) > 0 { if err := client.SendAudio(ctx, payload); err != nil { res.Error = "test payload not sent: " + err.Error() } else { res.TestPayloadSent = true } } _ = client.StopMediaBuffering(ctx, "selftest") select { case <-time.After(cfg.Duration): case <-ctx.Done(): } res.Stats = client.Stats() res.OK = res.ExternalMediaChannelCreated && res.ConnectionIDRetrieved && res.MediaWebSocketConnected && res.MediaStartReceived return res } func (g *Gateway) waitConnectionID(ctx context.Context, channelID string) (string, error) { var last error for i := 0; i < 25; i++ { v, err := g.actions.GetChannelVariable(ctx, channelID, "MEDIA_WEBSOCKET_CONNECTION_ID") if err == nil && v != "" { return v, nil } last = err select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(200 * time.Millisecond): } } if last != nil { return "", fmt.Errorf("MEDIA_WEBSOCKET_CONNECTION_ID not available: %w", last) } return "", fmt.Errorf("MEDIA_WEBSOCKET_CONNECTION_ID not available") } func shortID(prefix string) string { return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) } func ValidTestMode(v string) bool { switch TestMode(strings.ToLower(v)) { case TestModeStats, TestModeSilence, TestModeTone, TestModeEcho: return true } return false } func (g *Gateway) mediaCodec() media.Codec { switch media.Codec(g.cfg.Asterisk.MediaCodec) { case media.CodecULaw: return media.CodecULaw case media.CodecALaw: return media.CodecALaw default: return media.CodecSLIN16 } } func (g *Gateway) mediaSampleRate() int { switch g.mediaCodec() { case media.CodecULaw, media.CodecALaw: return 8000 default: return 16000 } } func (g *Gateway) StartCallMedia(ctx context.Context, session *call.CallSession) (call.MediaClient, error) { bridgeID := "aiop-bridge-" + safeID(session.CallID) mediaID := "aiop-media-" + safeID(session.CallID) session.BridgeID = bridgeID session.MediaChannelID = mediaID if err := g.actions.CreateBridge(ctx, bridgeID, "mixing,proxy_media,dtmf_events"); err != nil { return nil, err } if err := g.actions.AddChannelToBridge(ctx, bridgeID, session.AsteriskChannelID); err != nil { return nil, err } codec := g.mediaCodec() ch, err := g.actions.CreateExternalMediaChannel(ctx, ari.ExternalMediaRequest{ChannelID: mediaID, App: g.cfg.Asterisk.ARIApp, ExternalHost: "INCOMING", Encapsulation: "none", Transport: "websocket", ConnectionType: "server", Format: string(codec), Direction: "both", Data: session.CallID}) if err != nil { return nil, err } if ch.ID != "" { session.MediaChannelID = ch.ID } connID, err := g.waitConnectionID(ctx, session.MediaChannelID) if err != nil { return nil, err } session.MediaConnectionID = connID url, _, err := asteriskws.BuildMediaWebSocketURL(g.cfg.Asterisk.MediaWSBaseURL, connID) if err != nil { return nil, err } client := asteriskws.NewClient(asteriskws.ClientConfig{URL: url, ConnectionID: connID, Codec: codec, BasicAuthUser: g.cfg.Asterisk.ARIUser, BasicAuthPassword: g.cfg.Asterisk.ARIPassword}) if err := client.Connect(ctx); err != nil { return nil, err } session.MediaConnected = true if _, err := g.waitMediaStart(ctx, client, 5*time.Second); err != nil { _ = client.Close(context.Background()) return nil, err } if err := g.addMediaChannelToBridge(ctx, bridgeID, session.MediaChannelID); err != nil { _ = client.Close(context.Background()) return nil, err } go func() { <-ctx.Done() _ = client.Close(context.Background()) _ = g.actions.HangupChannel(context.Background(), session.MediaChannelID) _ = g.actions.RemoveChannelFromBridge(context.Background(), bridgeID, session.AsteriskChannelID) _ = g.actions.DeleteBridge(context.Background(), bridgeID) }() return client, nil } func (g *Gateway) waitMediaStart(ctx context.Context, client *asteriskws.Client, timeout time.Duration) (*media.MediaStartInfo, error) { if timeout <= 0 { timeout = 5 * time.Second } deadline := time.NewTimer(timeout) defer deadline.Stop() for { select { case ev, ok := <-client.Events(): if !ok { return nil, fmt.Errorf("media websocket closed before MEDIA_START") } if ev.Type == asteriskws.ControlMediaStart { ms := ev.MediaStart() if ms != nil { return ms, nil } } case <-deadline.C: return nil, fmt.Errorf("timeout waiting for MEDIA_START") case <-ctx.Done(): return nil, ctx.Err() } } } func (g *Gateway) addMediaChannelToBridge(ctx context.Context, bridgeID, channelID string) error { var lastErr error deadline := time.NewTimer(2 * time.Second) defer deadline.Stop() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { err := g.actions.AddChannelToBridge(ctx, bridgeID, channelID) if err == nil { return nil } lastErr = err if !strings.Contains(err.Error(), "HTTP 422") { return err } select { case <-ctx.Done(): return ctx.Err() case <-deadline.C: return lastErr case <-ticker.C: } } } func safeID(v string) string { r := strings.NewReplacer("/", "-", ":", "-", ".", "-") out := r.Replace(v) if len(out) > 32 { return out[:32] } return out }