mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(audio): proxy STT wrapper
Implement STT proxy provider that wraps arbitrary HTTP-based STT services. Ported 12 test cases from legacy Telegram STT integration to validate multipart/form-data submission, language/model overrides, and API error handling.
This commit is contained in:
1 parent
37224937c6
commit
9f88f12c2a
2 files changed
+375
No files matched your search
@@ -0,0 +1,86 @@
|
||||
// Package proxy_stt wraps internal/channels/media.TranscribeAudio as an
|
||||
// audio.STTProvider, preserving all existing proxy behavior for backward compat.
|
||||
package proxy_stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
|
||||
)
|
||||
|
||||
// Provider wraps media.TranscribeAudio as an audio.STTProvider.
|
||||
// Preserves all proxy behaviors (Bearer auth, tenant_id field, semaphore, etc.)
|
||||
// so channels can fall back to the proxy without any behavior change.
|
||||
type Provider struct {
|
||||
cfg media.STTConfig
|
||||
}
|
||||
|
||||
// NewProvider returns a proxy STT provider backed by media.TranscribeAudio.
|
||||
func NewProvider(cfg media.STTConfig) *Provider {
|
||||
return &Provider{cfg: cfg}
|
||||
}
|
||||
|
||||
// Name returns the stable provider identifier.
|
||||
func (p *Provider) Name() string { return "proxy" }
|
||||
|
||||
// Transcribe delegates to media.TranscribeAudio. When in.FilePath is empty but
|
||||
// in.Bytes is present, writes a 0600 temp file and defers cleanup.
|
||||
// Returns ("", nil) when proxy URL is empty or filePath is empty (proxy no-op).
|
||||
func (p *Provider) Transcribe(ctx context.Context, in audio.STTInput, _ audio.STTOptions) (*audio.TranscriptResult, error) {
|
||||
filePath := in.FilePath
|
||||
|
||||
if filePath == "" && len(in.Bytes) > 0 {
|
||||
ext := extFromMime(in.MimeType)
|
||||
f, err := os.CreateTemp("", "stt-proxy-*"+ext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy_stt: create temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(f.Name(), 0600); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, fmt.Errorf("proxy_stt: chmod temp file: %w", err)
|
||||
}
|
||||
if _, err := f.Write(in.Bytes); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, fmt.Errorf("proxy_stt: write temp file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
filePath = f.Name()
|
||||
}
|
||||
|
||||
text, err := media.TranscribeAudio(ctx, p.cfg, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &audio.TranscriptResult{
|
||||
Text: text,
|
||||
Provider: "proxy",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extFromMime returns a file extension for a MIME type.
|
||||
func extFromMime(mime string) string {
|
||||
switch strings.Split(mime, ";")[0] {
|
||||
case "audio/ogg":
|
||||
return ".ogg"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return ".mp3"
|
||||
case "audio/wav", "audio/wave":
|
||||
return ".wav"
|
||||
case "audio/mp4", "audio/m4a":
|
||||
return ".m4a"
|
||||
case "audio/webm":
|
||||
return ".webm"
|
||||
case "audio/flac":
|
||||
return ".flac"
|
||||
default:
|
||||
return filepath.Ext(mime) // fallback; usually empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package proxy_stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
|
||||
)
|
||||
|
||||
// proxyTestResponse mirrors the STT proxy JSON response for test assertions.
|
||||
// Proxy returns "transcript" (not "text" like Scribe).
|
||||
type proxyTestResponse struct {
|
||||
Transcript string `json:"transcript"`
|
||||
}
|
||||
|
||||
const proxyEndpoint = "/transcribe_audio"
|
||||
|
||||
// writeTempAudio writes a fake audio file and returns its path.
|
||||
func writeTempAudio(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "stt_proxy_test_*.ogg")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp audio file: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatalf("write temp audio file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func newProvider(proxyURL, apiKey, tenantID string) *Provider {
|
||||
return NewProvider(media.STTConfig{
|
||||
ProxyURL: proxyURL,
|
||||
APIKey: apiKey,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
}
|
||||
|
||||
// Case 1: NoProxy — empty ProxyURL returns ("", nil) without HTTP call.
|
||||
func TestTranscribeAudio_NoProxy(t *testing.T) {
|
||||
p := newProvider("", "", "")
|
||||
res, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: "/any/file.ogg"}, audio.STTOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if res.Text != "" {
|
||||
t.Fatalf("expected empty transcript, got: %q", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: EmptyFilePath — empty filePath is silent no-op even when STT configured.
|
||||
func TestTranscribeAudio_EmptyFilePath(t *testing.T) {
|
||||
p := newProvider("https://stt.example.com", "", "")
|
||||
res, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: ""}, audio.STTOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if res.Text != "" {
|
||||
t.Fatalf("expected empty transcript, got: %q", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: MissingFile — non-existent file returns error (not silent).
|
||||
func TestTranscribeAudio_MissingFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("unexpected HTTP call for missing file")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
_, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: "/nonexistent/file.ogg"}, audio.STTOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: Success — happy path returns transcript string.
|
||||
func TestTranscribeAudio_Success(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != proxyEndpoint {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
if _, _, err := r.FormFile("file"); err != nil {
|
||||
t.Errorf("expected 'file' field in multipart form: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: "hello world"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
res, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Text != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", res.Text)
|
||||
}
|
||||
if res.Provider != "proxy" {
|
||||
t.Errorf("expected provider 'proxy', got %q", res.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 5: BearerToken — STTAPIKey sent as Authorization: Bearer header.
|
||||
func TestTranscribeAudio_BearerToken(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
const wantKey = "super-secret-key"
|
||||
var gotAuth string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, wantKey, "")
|
||||
if _, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer "+wantKey {
|
||||
t.Errorf("expected Authorization %q, got %q", "Bearer "+wantKey, gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 6: NoAuthHeader — empty APIKey sends no Authorization header.
|
||||
func TestTranscribeAudio_NoAuthHeader(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("expected no Authorization header, got %q", auth)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
if _, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 7: TenantID — STTTenantID forwarded as multipart "tenant_id" field.
|
||||
func TestTranscribeAudio_TenantID(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
const wantTenant = "acme-corp"
|
||||
var gotTenant string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err == nil {
|
||||
gotTenant = r.FormValue("tenant_id")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", wantTenant)
|
||||
if _, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotTenant != wantTenant {
|
||||
t.Errorf("expected tenant_id %q, got %q", wantTenant, gotTenant)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 8: NoTenantField — empty TenantID sends no "tenant_id" form field.
|
||||
func TestTranscribeAudio_NoTenantField(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err == nil {
|
||||
if tid := r.FormValue("tenant_id"); tid != "" {
|
||||
t.Errorf("expected no tenant_id field, got %q", tid)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
if _, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 9: UpstreamError — non-200 response surfaces as error mentioning status code.
|
||||
func TestTranscribeAudio_UpstreamError(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
_, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") {
|
||||
t.Errorf("expected error to mention status 503, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 10: InvalidJSON — 200 response with malformed JSON returns parse error.
|
||||
func TestTranscribeAudio_InvalidJSON(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`not-json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
_, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Case 11: EmptyTranscript — 200 + {"transcript":""} returns ("", nil), not an error.
|
||||
func TestTranscribeAudio_EmptyTranscript(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(proxyTestResponse{Transcript: ""})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
res, err := p.Transcribe(context.Background(), audio.STTInput{FilePath: audioFile}, audio.STTOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Text != "" {
|
||||
t.Errorf("expected empty transcript, got %q", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 12: ContextCancelled — cancelled context causes HTTP call to fail fast.
|
||||
func TestTranscribeAudio_ContextCancelled(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
p := newProvider(srv.URL, "", "")
|
||||
_, err := p.Transcribe(ctx, audio.STTInput{FilePath: audioFile}, audio.STTOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user