309 lines
8.8 KiB
Go
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
|
|
}
|