Files
ai-operator/internal/media/gateway/asterisk.go
T

309 lines
8.8 KiB
Go

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
}