feat: complete MQ-only dispatcher and OSS upload flow

This commit is contained in:
2026-09-22 21:09:06 +08:00
parent 704652bd0d
commit a2fcc4aabb
265 changed files with 18689 additions and 1469 deletions
+67
View File
@@ -0,0 +1,67 @@
package callruntime
import (
"context"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/ai"
)
func validConfig() Config {
return Config{ARIURL: "http://127.0.0.1:1/ari", ARIWebsocketURL: "ws://127.0.0.1:1/ari/events", ARIApplication: "test", ARIUsername: "local", ARIPassword: "isolated-test-only", Endpoint: "PJSIP/local-test", MediaBind: "127.0.0.1", MediaPort: 20000, MediaFormat: "slin16", MediaSampleRate: 16000, PayloadType: 96, Pipeline: ai.MockPipeline{}}
}
func TestRuntimeRejectsInvalidConfigurationBeforeNetwork(t *testing.T) {
for _, tc := range []struct {
name string
change func(*Config)
}{
{"ARI URL", func(c *Config) { c.ARIURL = "" }},
{"websocket", func(c *Config) { c.ARIWebsocketURL = "" }},
{"application", func(c *Config) { c.ARIApplication = "" }},
{"username", func(c *Config) { c.ARIUsername = "" }},
{"password", func(c *Config) { c.ARIPassword = "" }},
{"endpoint", func(c *Config) { c.Endpoint = "" }},
{"media bind", func(c *Config) { c.MediaBind = "" }},
{"low media port", func(c *Config) { c.MediaPort = 1023 }},
{"high media port", func(c *Config) { c.MediaPort = 65536 }},
{"media format", func(c *Config) { c.MediaFormat = "invalid" }},
{"pipeline", func(c *Config) { c.Pipeline = nil }},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := validConfig()
tc.change(&cfg)
if err := cfg.validate(); err == nil {
t.Fatal("invalid configuration accepted")
}
if _, err := Run(context.Background(), cfg); err == nil {
t.Fatal("invalid configuration reached runtime")
}
})
}
if err := validConfig().validate(); err != nil {
t.Fatal(err)
}
}
func TestRuntimeNormalizationPreservesExplicitSettings(t *testing.T) {
cfg := validConfig()
cfg.AnswerTimeout = time.Second
cfg.TurnWindow = 2 * time.Second
cfg.FirstSpeechTimeout = 3 * time.Second
cfg.MaxTurnDuration = 4 * time.Second
cfg.EndSilence = 5 * time.Second
cfg.MaxCallDuration = 6 * time.Second
cfg.VoiceThreshold = 22
cfg.MaxTurns = 9
cfg.OpeningPrompt = "configured opening"
actual := cfg.normalized()
if actual.AnswerTimeout != cfg.AnswerTimeout || actual.TurnWindow != cfg.TurnWindow || actual.FirstSpeechTimeout != cfg.FirstSpeechTimeout || actual.MaxTurnDuration != cfg.MaxTurnDuration || actual.EndSilence != cfg.EndSilence || actual.MaxCallDuration != cfg.MaxCallDuration || actual.VoiceThreshold != cfg.VoiceThreshold || actual.MaxTurns != cfg.MaxTurns || actual.OpeningPrompt != cfg.OpeningPrompt {
t.Fatal("explicit conversation settings were overwritten")
}
defaults := (Config{}).normalized()
if defaults.MediaFormat != "slin16" || defaults.MediaSampleRate != 16000 || defaults.AnswerTimeout <= 0 || defaults.TurnWindow <= 0 {
t.Fatal("invalid normalized transport settings")
}
}
+85
View File
@@ -0,0 +1,85 @@
package callruntime
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/CyCoreSystems/ari/v5"
)
type eventSubscription struct{ events chan ari.Event }
func (s eventSubscription) Events() <-chan ari.Event { return s.events }
func (s eventSubscription) Cancel() {}
func events(values ...ari.Event) eventSubscription {
s := eventSubscription{make(chan ari.Event, len(values))}
for _, event := range values {
s.events <- event
}
close(s.events)
return s
}
func TestWaitForAnswerUsesOnlyTargetChannel(t *testing.T) {
for _, tc := range []struct {
name string
event ari.Event
wantError string
}{
{name: "stasis", event: &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "call"}}},
{name: "up", event: &ari.ChannelStateChange{EventData: ari.EventData{Type: "ChannelStateChange"}, Channel: ari.ChannelData{ID: "call", State: "Up"}}},
{name: "hangup", event: &ari.ChannelHangupRequest{EventData: ari.EventData{Type: "ChannelHangupRequest"}, Channel: ari.ChannelData{ID: "call", State: "Down"}, Cause: 17}, wantError: "cause=17"},
{name: "destroyed", event: &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "call"}}, wantError: "ended before StasisStart"},
} {
t.Run(tc.name, func(t *testing.T) {
other := &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "another-call"}}
err := waitForStasisStart(context.Background(), events(nil, other, tc.event), "call", time.Second)
if tc.wantError == "" && err != nil {
t.Fatal(err)
}
if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) {
t.Fatalf("unexpected answer result: %v", err)
}
})
}
if err := waitForStasisStart(context.Background(), events(), "call", time.Second); err == nil || !strings.Contains(err.Error(), "subscription closed") {
t.Fatal("closed subscription accepted")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForStasisStart(ctx, eventSubscription{make(chan ari.Event)}, "call", time.Second); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation lost: %v", err)
}
}
func TestLifecycleCancellationIsScopedToChannel(t *testing.T) {
for _, kind := range []string{"ChannelHangupRequest", "ChannelDestroyed"} {
t.Run(kind, func(t *testing.T) {
var event ari.Event
if kind == "ChannelDestroyed" {
event = &ari.ChannelDestroyed{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}}
} else {
event = &ari.ChannelHangupRequest{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
other := &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}}
watchChannelLifecycle(ctx, events(other, event), "call", cancel)
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatal("matching hangup failed to cancel")
}
})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watchChannelLifecycle(ctx, events(&ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}}), "call", cancel)
if ctx.Err() != nil {
t.Fatal("another call ended this call")
}
watchChannelLifecycle(ctx, events(nil), "call", cancel)
cancel()
watchChannelLifecycle(ctx, eventSubscription{make(chan ari.Event)}, "call", cancel)
}
+86
View File
@@ -0,0 +1,86 @@
package callruntime
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"os"
"path/filepath"
"testing"
"git.ipao.vip/rogee/go-sip/internal/callflow"
)
func TestConversationRecordingFactsMatchFiles(t *testing.T) {
directory := t.TempDir()
incoming := [][]byte{{1, 0, 2, 0}, make([]byte, 32000)}
outgoing := [][]byte{{4, 0}, {5, 0, 6, 0}}
in, out, err := persistConversationRecordings(directory, "channel/with:separators", callflow.Result{InboundTurns: incoming, OutboundTurns: outgoing})
if err != nil {
t.Fatal(err)
}
if len(in) != 2 || len(out) != 2 {
t.Fatal("lost recording segments")
}
for side, facts := range [][]RecordingFact{in, out} {
expected := [][][]byte{incoming, outgoing}[side]
for index, fact := range facts {
if filepath.Dir(fact.Path) != directory {
t.Fatal("recording escaped directory")
}
raw, err := os.ReadFile(fact.Path)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(raw)
if fact.SHA256 != hex.EncodeToString(sum[:]) || fact.Bytes != len(raw) || fact.DurationMS != int64(len(expected[index]))*1000/32000 {
t.Fatal("recording fact does not match content")
}
if string(raw[:4]) != "RIFF" || string(raw[8:12]) != "WAVE" || string(raw[36:40]) != "data" {
t.Fatal("invalid WAV header")
}
if binary.LittleEndian.Uint32(raw[24:28]) != 16000 || binary.LittleEndian.Uint32(raw[40:44]) != uint32(len(expected[index])) || !bytes.Equal(raw[44:], expected[index]) {
t.Fatal("incorrect WAV format or payload")
}
info, err := os.Stat(fact.Path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatal("recording is not restricted")
}
}
}
if in[0].Segment != "inbound_turn_01" || out[0].Segment != "outbound_segment_00" {
t.Fatal("segment identities changed")
}
}
func TestRecordingFailuresAreExplicit(t *testing.T) {
directory := t.TempDir()
if err := writeWAV(filepath.Join(directory, "odd.wav"), []byte{1}, 16000); err == nil {
t.Fatal("odd PCM accepted")
}
blocker := filepath.Join(directory, "file")
if err := os.WriteFile(blocker, []byte("blocked"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := persistConversationRecordings(filepath.Join(blocker, "child"), "channel", callflow.Result{InboundTurns: [][]byte{{0, 0}}}); err == nil {
t.Fatal("directory creation error hidden")
}
for _, flow := range []callflow.Result{{InboundTurns: [][]byte{{1}}}, {OutboundTurns: [][]byte{{1}}}} {
if _, _, err := persistConversationRecordings(directory, "channel", flow); err == nil {
t.Fatal("invalid segment accepted")
}
}
for _, dir := range []string{"", directory} {
in, out, err := persistConversationRecordings(dir, "empty", callflow.Result{})
if err != nil || len(in) != 0 || len(out) != 0 {
t.Fatal("empty recording produced facts")
}
}
if _, _, err := fileDigestAndSize(filepath.Join(directory, "missing.wav")); err == nil {
t.Fatal("missing recording silently returned a checksum")
}
}
+18 -9
View File
@@ -62,10 +62,11 @@ type Config struct {
}
type RecordingFact struct {
Segment string
Path string
SHA256 string
Bytes int
Segment string
Path string
SHA256 string
Bytes int // Complete file size, including the WAV header.
DurationMS int64
}
type Result struct {
@@ -385,7 +386,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf
if err := writeWAV(path, pcm, 16000); err != nil {
return inbound, nil, err
}
inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)})
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 {
@@ -393,7 +398,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf
if err := writeWAV(path, pcm, 16000); err != nil {
return inbound, outbound, err
}
outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)})
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
}
@@ -423,11 +432,11 @@ func writeWAV(path string, pcm []byte, sampleRate int) error {
return os.WriteFile(path, buf, 0o600)
}
func fileSHA256(path string) string {
func fileDigestAndSize(path string) (string, int, error) {
data, err := os.ReadFile(path)
if err != nil {
return ""
return "", 0, err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
return hex.EncodeToString(sum[:]), len(data), nil
}