sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user