443 lines
14 KiB
Go
443 lines
14 KiB
Go
package callruntime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
|
"git.ipao.vip/rogee/go-sip/internal/media"
|
|
"github.com/CyCoreSystems/ari/v5"
|
|
"github.com/CyCoreSystems/ari/v5/client/native"
|
|
)
|
|
|
|
const (
|
|
defaultAnswerTimeout = 45 * time.Second
|
|
defaultTurnWindow = 5 * time.Second
|
|
defaultFirstSpeechWait = 20 * time.Second
|
|
defaultMaxTurnDuration = 12 * time.Second
|
|
defaultEndSilence = 900 * time.Millisecond
|
|
defaultVoiceThreshold = 350
|
|
defaultConversationTurns = 3
|
|
defaultCallDuration = 2 * time.Minute
|
|
)
|
|
|
|
type Config struct {
|
|
ARIURL string
|
|
ARIWebsocketURL string
|
|
ARIApplication string
|
|
ARIUsername string
|
|
ARIPassword string
|
|
|
|
Endpoint string
|
|
CallerID string
|
|
MediaBind string
|
|
MediaPort int
|
|
MediaFormat string
|
|
MediaSampleRate int
|
|
PayloadType uint8
|
|
|
|
RecordingDirectory string
|
|
AnswerTimeout time.Duration
|
|
TurnWindow time.Duration
|
|
FirstSpeechTimeout time.Duration
|
|
MaxTurnDuration time.Duration
|
|
EndSilence time.Duration
|
|
VoiceThreshold int
|
|
MaxTurns int
|
|
MaxCallDuration time.Duration
|
|
OpeningPrompt string
|
|
|
|
Snapshot ai.Snapshot
|
|
Pipeline ai.Pipeline
|
|
}
|
|
|
|
type RecordingFact struct {
|
|
Segment string
|
|
Path string
|
|
SHA256 string
|
|
Bytes int // Complete file size, including the WAV header.
|
|
DurationMS int64
|
|
}
|
|
|
|
type Result struct {
|
|
ChannelID string
|
|
Transcript string
|
|
Reply string
|
|
InvalidCall bool
|
|
InvalidReason string
|
|
Turns []ai.TurnResult
|
|
InboundPath string
|
|
InboundSHA256 string
|
|
InboundBytes int
|
|
OutboundPath string
|
|
OutboundSHA256 string
|
|
OutboundBytes int
|
|
InboundRecordings []RecordingFact
|
|
OutboundRecordings []RecordingFact
|
|
RTP media.RTPStats
|
|
}
|
|
|
|
func (c Config) validate() error {
|
|
if c.ARIApplication == "" || c.ARIURL == "" || c.ARIWebsocketURL == "" {
|
|
return errors.New("ARI URL, websocket URL and application are required")
|
|
}
|
|
if c.ARIPassword == "" || c.ARIUsername == "" {
|
|
return errors.New("ARI credentials are required")
|
|
}
|
|
if c.Endpoint == "" {
|
|
return errors.New("endpoint is required")
|
|
}
|
|
if c.MediaBind == "" || c.MediaPort < 1024 || c.MediaPort > 65535 {
|
|
return errors.New("valid media bind and port are required")
|
|
}
|
|
if err := media.ValidateFormat(media.Format(c.MediaFormat), c.PayloadType, c.MediaSampleRate); err != nil {
|
|
return err
|
|
}
|
|
if c.Pipeline == nil {
|
|
return errors.New("AI pipeline is required")
|
|
}
|
|
if c.AnswerTimeout <= 0 {
|
|
c.AnswerTimeout = defaultAnswerTimeout
|
|
}
|
|
if c.TurnWindow <= 0 {
|
|
c.TurnWindow = defaultTurnWindow
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Config) normalized() Config {
|
|
if c.AnswerTimeout <= 0 {
|
|
c.AnswerTimeout = defaultAnswerTimeout
|
|
}
|
|
if c.TurnWindow <= 0 {
|
|
c.TurnWindow = defaultTurnWindow
|
|
}
|
|
if c.MediaFormat == "" {
|
|
c.MediaFormat = string(media.FormatSLIN16)
|
|
}
|
|
if c.MediaSampleRate <= 0 {
|
|
c.MediaSampleRate = 16000
|
|
}
|
|
if c.FirstSpeechTimeout <= 0 {
|
|
c.FirstSpeechTimeout = defaultFirstSpeechWait
|
|
}
|
|
if c.MaxTurnDuration <= 0 {
|
|
c.MaxTurnDuration = defaultMaxTurnDuration
|
|
}
|
|
if c.EndSilence <= 0 {
|
|
c.EndSilence = defaultEndSilence
|
|
}
|
|
if c.VoiceThreshold <= 0 {
|
|
c.VoiceThreshold = defaultVoiceThreshold
|
|
}
|
|
if c.MaxTurns <= 0 {
|
|
c.MaxTurns = defaultConversationTurns
|
|
}
|
|
if c.MaxCallDuration <= 0 {
|
|
c.MaxCallDuration = defaultCallDuration
|
|
}
|
|
if c.OpeningPrompt == "" {
|
|
c.OpeningPrompt = "您好,请说出您想咨询的内容。"
|
|
}
|
|
return c
|
|
}
|
|
|
|
func Run(ctx context.Context, cfg Config) (Result, error) {
|
|
cfg = cfg.normalized()
|
|
if err := cfg.validate(); err != nil {
|
|
return Result{}, err
|
|
}
|
|
runCtx, cancelRun := context.WithTimeout(ctx, cfg.MaxCallDuration)
|
|
defer cancelRun()
|
|
stream, err := media.ListenRTPWithFormat(fmt.Sprintf("%s:%d", cfg.MediaBind, cfg.MediaPort), cfg.PayloadType, media.Format(cfg.MediaFormat), cfg.MediaSampleRate)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("bind external media: %w", err)
|
|
}
|
|
defer stream.Close()
|
|
|
|
client, err := native.Connect(&native.Options{
|
|
URL: cfg.ARIURL,
|
|
WebsocketURL: cfg.ARIWebsocketURL,
|
|
Application: cfg.ARIApplication,
|
|
Username: cfg.ARIUsername,
|
|
Password: cfg.ARIPassword,
|
|
SubscribeAll: true,
|
|
})
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("connect ARI: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
starts := client.Bus().Subscribe(nil, ari.Events.All)
|
|
defer starts.Cancel()
|
|
originate, err := client.Channel().Originate(nil, ari.OriginateRequest{
|
|
Endpoint: cfg.Endpoint,
|
|
// ari.OriginateRequest.Timeout is specified in seconds.
|
|
Timeout: int(cfg.AnswerTimeout / time.Second),
|
|
CallerID: cfg.CallerID,
|
|
App: cfg.ARIApplication,
|
|
Formats: cfg.MediaFormat,
|
|
})
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("originate %s: %w", cfg.Endpoint, err)
|
|
}
|
|
channelID := originate.ID()
|
|
channelKey := ari.NewKey(ari.ChannelKey, channelID)
|
|
var bridgeKey *ari.Key
|
|
var externalKey *ari.Key
|
|
defer func() {
|
|
if externalKey != nil {
|
|
_ = client.Channel().Hangup(externalKey, "normal")
|
|
}
|
|
if channelKey != nil {
|
|
_ = client.Channel().Hangup(channelKey, "normal")
|
|
}
|
|
if bridgeKey != nil {
|
|
_ = client.Bridge().Delete(bridgeKey)
|
|
}
|
|
}()
|
|
|
|
if err := waitForStasisStart(runCtx, starts, channelID, cfg.AnswerTimeout); err != nil {
|
|
return Result{}, err
|
|
}
|
|
callCtx, cancelCall := context.WithCancel(runCtx)
|
|
defer cancelCall()
|
|
go watchChannelLifecycle(callCtx, starts, channelID, cancelCall)
|
|
if err := client.Channel().Answer(channelKey); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already") {
|
|
return Result{}, fmt.Errorf("answer channel: %w", err)
|
|
}
|
|
|
|
bridgeID := "agent-call-" + channelID
|
|
bridge, err := client.Bridge().Create(ari.NewKey(ari.BridgeKey, bridgeID), "mixing", bridgeID)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create media bridge: %w", err)
|
|
}
|
|
bridgeKey = ari.NewKey(ari.BridgeKey, bridge.ID())
|
|
if err := client.Bridge().AddChannel(bridgeKey, channelID); err != nil {
|
|
return Result{}, fmt.Errorf("add call channel to bridge: %w", err)
|
|
}
|
|
external, err := client.Channel().ExternalMedia(nil, ari.ExternalMediaOptions{
|
|
App: cfg.ARIApplication,
|
|
ExternalHost: fmt.Sprintf("%s:%d", cfg.MediaBind, cfg.MediaPort),
|
|
Encapsulation: "rtp",
|
|
Transport: "udp",
|
|
ConnectionType: "client",
|
|
Format: cfg.MediaFormat,
|
|
Direction: "both",
|
|
})
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create external media channel: %w", err)
|
|
}
|
|
externalKey = ari.NewKey(ari.ChannelKey, external.ID())
|
|
address, err := external.GetVariable("UNICASTRTP_LOCAL_ADDRESS")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("read ExternalMedia RTP address: %w", err)
|
|
}
|
|
port, err := external.GetVariable("UNICASTRTP_LOCAL_PORT")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("read ExternalMedia RTP port: %w", err)
|
|
}
|
|
if err := stream.SetPeer(net.JoinHostPort(strings.TrimSpace(address), strings.TrimSpace(port))); err != nil {
|
|
return Result{}, fmt.Errorf("configure ExternalMedia RTP peer: %w", err)
|
|
}
|
|
if err := client.Bridge().AddChannel(bridgeKey, external.ID()); err != nil {
|
|
return Result{}, fmt.Errorf("add external media to bridge: %w", err)
|
|
}
|
|
|
|
flowResult, flowErr := callflow.ExecuteWithCapture(callCtx, stream, cfg.Pipeline, cfg.Snapshot, cfg.OpeningPrompt, callflow.CaptureConfig{
|
|
FirstSpeechTimeout: cfg.FirstSpeechTimeout,
|
|
MaxDuration: cfg.MaxTurnDuration,
|
|
EndSilence: cfg.EndSilence,
|
|
VoiceThreshold: cfg.VoiceThreshold,
|
|
MaxTurns: cfg.MaxTurns,
|
|
})
|
|
inboundRecordings, outboundRecordings, recordingErr := persistConversationRecordings(cfg.RecordingDirectory, channelID, flowResult)
|
|
if recordingErr != nil {
|
|
return Result{}, recordingErr
|
|
}
|
|
turn := flowResult.Turn
|
|
result := Result{
|
|
ChannelID: channelID,
|
|
Transcript: turn.Transcript,
|
|
Reply: turn.Reply,
|
|
InvalidCall: turn.InvalidCall,
|
|
InvalidReason: turn.InvalidReason,
|
|
Turns: append([]ai.TurnResult(nil), flowResult.Turns...),
|
|
InboundRecordings: inboundRecordings,
|
|
OutboundRecordings: outboundRecordings,
|
|
RTP: flowResult.RTP,
|
|
}
|
|
if len(inboundRecordings) > 0 {
|
|
last := inboundRecordings[len(inboundRecordings)-1]
|
|
result.InboundPath, result.InboundBytes, result.InboundSHA256 = last.Path, last.Bytes, last.SHA256
|
|
}
|
|
if len(outboundRecordings) > 0 {
|
|
last := outboundRecordings[len(outboundRecordings)-1]
|
|
result.OutboundPath, result.OutboundBytes, result.OutboundSHA256 = last.Path, last.Bytes, last.SHA256
|
|
}
|
|
if flowErr != nil {
|
|
stats := stream.Stats()
|
|
return result, fmt.Errorf("execute call flow: %w (rtp rx_packets=%d rx_bytes=%d tx_packets=%d tx_bytes=%d)", flowErr, stats.ReceivedPackets, stats.ReceivedBytes, stats.SentPackets, stats.SentBytes)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func watchChannelLifecycle(ctx context.Context, sub ari.Subscription, channelID string, cancel context.CancelFunc) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event, ok := <-sub.Events():
|
|
if !ok || event == nil {
|
|
return
|
|
}
|
|
matched := false
|
|
for _, key := range event.Keys() {
|
|
if key != nil && key.ID == channelID {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
switch event.GetType() {
|
|
case "ChannelHangupRequest", "ChannelDestroyed":
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForStasisStart(ctx context.Context, sub ari.Subscription, channelID string, timeout time.Duration) error {
|
|
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
var recent []string
|
|
for {
|
|
select {
|
|
case <-waitCtx.Done():
|
|
return fmt.Errorf("waiting for channel %s answer/StasisStart (recent_events=%s): %w", channelID, strings.Join(recent, ","), waitCtx.Err())
|
|
case event, ok := <-sub.Events():
|
|
if !ok {
|
|
return errors.New("ARI event subscription closed")
|
|
}
|
|
if event == nil {
|
|
continue
|
|
}
|
|
typ := event.GetType()
|
|
recent = append(recent, typ)
|
|
if len(recent) > 8 {
|
|
recent = recent[len(recent)-8:]
|
|
}
|
|
matched := false
|
|
for _, key := range event.Keys() {
|
|
if key != nil && key.ID == channelID {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
if typ == "StasisStart" {
|
|
return nil
|
|
}
|
|
if typ == "ChannelStateChange" {
|
|
if state, ok := event.(*ari.ChannelStateChange); ok && strings.EqualFold(state.Channel.State, "Up") {
|
|
return nil
|
|
}
|
|
}
|
|
if typ == "ChannelHangupRequest" {
|
|
if hangup, ok := event.(*ari.ChannelHangupRequest); ok {
|
|
return fmt.Errorf("channel %s ended before StasisStart: %s cause=%d soft=%t state=%s", channelID, typ, hangup.Cause, hangup.Soft, hangup.Channel.State)
|
|
}
|
|
return fmt.Errorf("channel %s ended before StasisStart: %s", channelID, typ)
|
|
}
|
|
if typ == "ChannelDestroyed" {
|
|
return fmt.Errorf("channel %s ended before StasisStart: %s", channelID, typ)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func persistConversationRecordings(directory, channelID string, flowResult callflow.Result) ([]RecordingFact, []RecordingFact, error) {
|
|
if directory == "" || (len(flowResult.InboundTurns) == 0 && len(flowResult.OutboundTurns) == 0) {
|
|
return nil, nil, nil
|
|
}
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
|
|
name := strings.NewReplacer("/", "_", "\\", "_", ":", "_").Replace(channelID)
|
|
inbound := make([]RecordingFact, 0, len(flowResult.InboundTurns))
|
|
for index, pcm := range flowResult.InboundTurns {
|
|
path := filepath.Join(directory, fmt.Sprintf("%s-%s-inbound-turn-%02d.wav", stamp, name, index+1))
|
|
if err := writeWAV(path, pcm, 16000); err != nil {
|
|
return inbound, nil, err
|
|
}
|
|
digest, size, err := fileDigestAndSize(path)
|
|
if err != nil {
|
|
return inbound, nil, fmt.Errorf("hash inbound recording: %w", err)
|
|
}
|
|
inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000})
|
|
}
|
|
outbound := make([]RecordingFact, 0, len(flowResult.OutboundTurns))
|
|
for index, pcm := range flowResult.OutboundTurns {
|
|
path := filepath.Join(directory, fmt.Sprintf("%s-%s-outbound-segment-%02d.wav", stamp, name, index))
|
|
if err := writeWAV(path, pcm, 16000); err != nil {
|
|
return inbound, outbound, err
|
|
}
|
|
digest, size, err := fileDigestAndSize(path)
|
|
if err != nil {
|
|
return inbound, outbound, fmt.Errorf("hash outbound recording: %w", err)
|
|
}
|
|
outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000})
|
|
}
|
|
return inbound, outbound, nil
|
|
}
|
|
|
|
func writeWAV(path string, pcm []byte, sampleRate int) error {
|
|
if len(pcm)%2 != 0 {
|
|
return errors.New("PCM16 has odd byte length")
|
|
}
|
|
dataSize := uint32(len(pcm))
|
|
byteRate := uint32(sampleRate * 2)
|
|
blockAlign := uint16(2)
|
|
buf := make([]byte, 44+len(pcm))
|
|
copy(buf[:4], "RIFF")
|
|
binary.LittleEndian.PutUint32(buf[4:8], 36+dataSize)
|
|
copy(buf[8:12], "WAVE")
|
|
copy(buf[12:16], "fmt ")
|
|
binary.LittleEndian.PutUint32(buf[16:20], 16)
|
|
binary.LittleEndian.PutUint16(buf[20:22], 1)
|
|
binary.LittleEndian.PutUint16(buf[22:24], 1)
|
|
binary.LittleEndian.PutUint32(buf[24:28], uint32(sampleRate))
|
|
binary.LittleEndian.PutUint32(buf[28:32], byteRate)
|
|
binary.LittleEndian.PutUint16(buf[32:34], blockAlign)
|
|
binary.LittleEndian.PutUint16(buf[34:36], 16)
|
|
copy(buf[36:40], "data")
|
|
binary.LittleEndian.PutUint32(buf[40:44], dataSize)
|
|
copy(buf[44:], pcm)
|
|
return os.WriteFile(path, buf, 0o600)
|
|
}
|
|
|
|
func fileDigestAndSize(path string) (string, int, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:]), len(data), nil
|
|
}
|