2026-08-26 06:41:07 +08:00
|
|
|
package mockprovider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/hmac"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"data-terminal-cloud/internal/voiceprovider"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Options struct {
|
|
|
|
|
APIKey string
|
|
|
|
|
CallbackURL string
|
|
|
|
|
CallbackSecret string
|
|
|
|
|
StartDelay time.Duration
|
|
|
|
|
DuplicateCallbacks int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type task struct {
|
|
|
|
|
Request voiceprovider.StartRequest
|
|
|
|
|
Status string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Server struct {
|
|
|
|
|
options Options
|
|
|
|
|
logger *slog.Logger
|
|
|
|
|
client *http.Client
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
tasks map[string]*task
|
|
|
|
|
available bool
|
|
|
|
|
startCalls int
|
|
|
|
|
stopCalls int
|
|
|
|
|
now func() time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func New(options Options, logger *slog.Logger) (*Server, error) {
|
|
|
|
|
if len(options.APIKey) < 16 || options.CallbackURL == "" || len(options.CallbackSecret) < 32 || options.StartDelay < 0 || options.DuplicateCallbacks < 1 {
|
|
|
|
|
return nil, errors.New("valid mock provider options are required")
|
|
|
|
|
}
|
|
|
|
|
if logger == nil {
|
|
|
|
|
logger = slog.Default()
|
|
|
|
|
}
|
|
|
|
|
return &Server{options: options, logger: logger, client: &http.Client{Timeout: 5 * time.Second}, tasks: map[string]*task{}, available: true, now: time.Now}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_, _ = writer.Write([]byte(`{"status":"alive","service":"mock-provider"}`))
|
|
|
|
|
})
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/voice-chats:start", s.start)
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/voice-chats:query", s.query)
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/voice-chats:stop", s.stop)
|
|
|
|
|
mux.HandleFunc("GET /mock/v1/admin/state", s.adminState)
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/admin/availability", s.adminAvailability)
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/admin/reset", s.adminReset)
|
|
|
|
|
mux.HandleFunc("POST /mock/v1/admin/replay", s.adminReplay)
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) query(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !s.authorized(request) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var input voiceprovider.QueryRequest
|
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20))
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.ConversationID == "" {
|
|
|
|
|
http.Error(writer, "invalid request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
available := s.available
|
|
|
|
|
existing := s.tasks[input.TaskRef]
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if !available {
|
|
|
|
|
http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if existing == nil {
|
|
|
|
|
http.Error(writer, "task not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if existing.Request.ConversationID != input.ConversationID {
|
|
|
|
|
http.Error(writer, "task reference conflict", http.StatusConflict)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(voiceprovider.QueryResponse{TaskRef: input.TaskRef, Status: existing.Status})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) start(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey)) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.startCalls++
|
|
|
|
|
available := s.available
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if !available {
|
|
|
|
|
http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20))
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
var input voiceprovider.StartRequest
|
|
|
|
|
if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.RoomID == "" || input.DeviceUserID == "" || input.AgentUserID == "" || input.ConversationID == "" || !supportedEngineMode(input.EngineMode) {
|
|
|
|
|
http.Error(writer, "invalid request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
existing, found := s.tasks[input.TaskRef]
|
|
|
|
|
if found && existing.Request != input {
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
http.Error(writer, "task reference conflict", http.StatusConflict)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
existing = &task{Request: input, Status: "STARTING"}
|
|
|
|
|
s.tasks[input.TaskRef] = existing
|
|
|
|
|
}
|
|
|
|
|
status := existing.Status
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if !found {
|
|
|
|
|
go s.completeStart(input)
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
writer.WriteHeader(http.StatusAccepted)
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(voiceprovider.StartResponse{TaskRef: input.TaskRef, Status: status})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func supportedEngineMode(mode string) bool {
|
|
|
|
|
return mode == "s2s_pure" || mode == "s2s_hybrid" || mode == "cascade"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) completeStart(input voiceprovider.StartRequest) {
|
|
|
|
|
timer := time.NewTimer(s.options.StartDelay)
|
|
|
|
|
defer timer.Stop()
|
|
|
|
|
<-timer.C
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
if existing := s.tasks[input.TaskRef]; existing != nil {
|
|
|
|
|
if existing.Status != "STARTING" {
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
existing.Status = "ACTIVE"
|
|
|
|
|
}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
digest := sha256.Sum256([]byte(input.TaskRef))
|
|
|
|
|
event := voiceprovider.CallbackEvent{
|
|
|
|
|
Provider: "mock", EventID: "mock-started-" + hex.EncodeToString(digest[:12]),
|
|
|
|
|
TaskRef: input.TaskRef, ConversationID: input.ConversationID,
|
|
|
|
|
EventType: "voice_chat.started", Status: "ACTIVE", OccurredAt: s.now().UTC(),
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(event)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for index := 0; index < s.options.DuplicateCallbacks; index++ {
|
|
|
|
|
if err := s.sendCallback(body); err != nil {
|
|
|
|
|
s.logger.Warn("mock provider callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) stop(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey)) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.stopCalls++
|
|
|
|
|
available := s.available
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if !available {
|
|
|
|
|
http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20))
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
var input voiceprovider.StopRequest
|
|
|
|
|
if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.ConversationID == "" || input.Reason == "" {
|
|
|
|
|
http.Error(writer, "invalid request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
existing, found := s.tasks[input.TaskRef]
|
|
|
|
|
if !found {
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
http.Error(writer, "task not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if existing.Request.ConversationID != input.ConversationID {
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
http.Error(writer, "task reference conflict", http.StatusConflict)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
shouldComplete := existing.Status != "STOPPING" && existing.Status != "COMPLETED"
|
|
|
|
|
if shouldComplete {
|
|
|
|
|
existing.Status = "STOPPING"
|
|
|
|
|
}
|
|
|
|
|
status := existing.Status
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if shouldComplete {
|
|
|
|
|
go s.completeStop(existing.Request)
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
writer.WriteHeader(http.StatusAccepted)
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(voiceprovider.StopResponse{TaskRef: input.TaskRef, Status: status})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) adminState(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !s.authorized(request) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
state := struct {
|
|
|
|
|
Available bool `json:"available"`
|
|
|
|
|
StartCalls int `json:"start_calls"`
|
|
|
|
|
StopCalls int `json:"stop_calls"`
|
|
|
|
|
Tasks int `json:"tasks"`
|
|
|
|
|
}{Available: s.available, StartCalls: s.startCalls, StopCalls: s.stopCalls, Tasks: len(s.tasks)}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) adminAvailability(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !s.authorized(request) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var input struct {
|
|
|
|
|
Available *bool `json:"available"`
|
|
|
|
|
}
|
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(request.Body, 1024))
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
if err := decoder.Decode(&input); err != nil || input.Available == nil {
|
|
|
|
|
http.Error(writer, "invalid request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.available = *input.Available
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(map[string]bool{"available": *input.Available})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) adminReset(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !s.authorized(request) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.available = true
|
|
|
|
|
s.startCalls = 0
|
|
|
|
|
s.stopCalls = 0
|
|
|
|
|
s.tasks = map[string]*task{}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
writer.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) adminReplay(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if !s.authorized(request) {
|
|
|
|
|
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var input struct {
|
|
|
|
|
TaskRef string `json:"task_ref"`
|
|
|
|
|
Sample string `json:"sample"`
|
|
|
|
|
DuplicateCount int `json:"duplicate_count"`
|
|
|
|
|
}
|
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20))
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.Sample == "" || input.DuplicateCount < 0 || input.DuplicateCount > 10 {
|
|
|
|
|
http.Error(writer, "invalid request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if input.DuplicateCount == 0 {
|
|
|
|
|
input.DuplicateCount = 1
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
existing := s.tasks[input.TaskRef]
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
if existing == nil {
|
|
|
|
|
http.Error(writer, "task not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
event, err := s.sampleEvent(existing.Request, input.Sample)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(writer, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(event)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(writer, "encode sample", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for attempt := 0; attempt < input.DuplicateCount; attempt++ {
|
|
|
|
|
if err := s.sendCallback(body); err != nil {
|
|
|
|
|
http.Error(writer, "callback delivery failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
|
writer.WriteHeader(http.StatusAccepted)
|
|
|
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{"sample": input.Sample, "deliveries": input.DuplicateCount, "event_id": event.EventID})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) sampleEvent(input voiceprovider.StartRequest, sample string) (voiceprovider.CallbackEvent, error) {
|
|
|
|
|
now := s.now().UTC()
|
|
|
|
|
digest := sha256.Sum256([]byte("sample:" + sample + ":" + input.TaskRef))
|
|
|
|
|
event := voiceprovider.CallbackEvent{
|
|
|
|
|
Provider: "mock", EventID: "mock-sample-" + sample + "-" + hex.EncodeToString(digest[:8]),
|
|
|
|
|
TaskRef: input.TaskRef, ConversationID: input.ConversationID, OccurredAt: now,
|
|
|
|
|
}
|
|
|
|
|
switch sample {
|
|
|
|
|
case "started":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.started", "ACTIVE"
|
|
|
|
|
case "final_transcript":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.transcript.final", "FINAL"
|
|
|
|
|
event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-subject-101", SequenceNo: 101, Speaker: "subject", StartAt: now.Add(-3 * time.Second), EndAt: now.Add(-2 * time.Second), FinalText: "这是脱敏的长者最终字幕样例。", ProviderRef: "sample-subject-101"}}
|
|
|
|
|
case "agent_answering":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.transcript.final", "FINAL"
|
|
|
|
|
event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-agent-102", SequenceNo: 102, Speaker: "agent", StartAt: now.Add(-2 * time.Second), EndAt: now.Add(-time.Second), FinalText: "这是智能体开始回答的脱敏样例。", ProviderRef: "sample-agent-102"}}
|
|
|
|
|
case "interrupted":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.transcript.final", "FINAL"
|
|
|
|
|
event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-agent-103", SequenceNo: 103, Speaker: "agent", StartAt: now.Add(-time.Second), EndAt: now, FinalText: "这是被打断的智能体回答样例。", Interrupted: true, ProviderRef: "sample-agent-103"}}
|
|
|
|
|
case "usage":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.usage", "FINAL"
|
|
|
|
|
event.Usage = []voiceprovider.UsageMetric{{UsageCategory: "RTC", MetricCode: "rtc.minutes", Quantity: "1", Unit: "minutes"}}
|
|
|
|
|
case "error":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.failed", "FAILED"
|
|
|
|
|
case "stopped":
|
|
|
|
|
event.EventType, event.Status = "voice_chat.stopped", "COMPLETED"
|
|
|
|
|
default:
|
|
|
|
|
return voiceprovider.CallbackEvent{}, errors.New("unknown sample")
|
|
|
|
|
}
|
|
|
|
|
return event, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) authorized(request *http.Request) bool {
|
|
|
|
|
return hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) completeStop(input voiceprovider.StartRequest) {
|
|
|
|
|
timer := time.NewTimer(s.options.StartDelay)
|
|
|
|
|
defer timer.Stop()
|
|
|
|
|
<-timer.C
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
if existing := s.tasks[input.TaskRef]; existing != nil {
|
|
|
|
|
existing.Status = "COMPLETED"
|
|
|
|
|
}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
s.sendFinalTranscript(input)
|
|
|
|
|
digest := sha256.Sum256([]byte("stop:" + input.TaskRef))
|
|
|
|
|
event := voiceprovider.CallbackEvent{
|
|
|
|
|
Provider: "mock", EventID: "mock-stopped-" + hex.EncodeToString(digest[:12]),
|
|
|
|
|
TaskRef: input.TaskRef, ConversationID: input.ConversationID,
|
|
|
|
|
EventType: "voice_chat.stopped", Status: "COMPLETED", OccurredAt: s.now().UTC(),
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(event)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for index := 0; index < s.options.DuplicateCallbacks; index++ {
|
|
|
|
|
if err := s.sendCallback(body); err != nil {
|
|
|
|
|
s.logger.Warn("mock provider stop callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) sendFinalTranscript(input voiceprovider.StartRequest) {
|
|
|
|
|
base := s.now().UTC().Add(-20 * time.Second)
|
|
|
|
|
digest := sha256.Sum256([]byte("transcript:" + input.TaskRef))
|
|
|
|
|
event := voiceprovider.CallbackEvent{
|
|
|
|
|
Provider: "mock", EventID: "mock-transcript-" + hex.EncodeToString(digest[:12]),
|
|
|
|
|
TaskRef: input.TaskRef, ConversationID: input.ConversationID,
|
|
|
|
|
EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(),
|
|
|
|
|
Turns: []voiceprovider.FinalTurn{
|
2026-08-30 15:20:08 +08:00
|
|
|
{TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常和姐姐在家乡河边散步。有一次春天突然下雨,我们躲在老柳树下,后来一路笑着跑回家,现在想起来还是很高兴。", ProviderRef: "mock-turn-1"},
|
2026-08-26 06:41:07 +08:00
|
|
|
{TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(event)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for index := 0; index < s.options.DuplicateCallbacks; index++ {
|
|
|
|
|
if err := s.sendCallback(body); err != nil {
|
|
|
|
|
s.logger.Warn("mock provider transcript callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) sendCallback(body []byte) error {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.options.CallbackURL, bytes.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
timestamp := s.now().UTC().Format(time.RFC3339)
|
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
request.Header.Set(voiceprovider.HeaderCallbackTimestamp, timestamp)
|
|
|
|
|
request.Header.Set(voiceprovider.HeaderCallbackSignature,
|
|
|
|
|
voiceprovider.SignCallback([]byte(s.options.CallbackSecret), timestamp, body))
|
|
|
|
|
response, err := s.client.Do(request)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer response.Body.Close()
|
|
|
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
|
|
|
return errors.New("callback endpoint rejected event")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|