feat(audio): legacy STT bridge for per-channel STTProxyURL

Implement legacy STT bridge adapter to support existing per-channel STTProxyURL database configuration. Wraps arbitrary proxy endpoints and auto-registers them via Manager at boot. Includes integration tests for URL validation, authentication, and error handling.
This commit is contained in:
viettranx
2026-04-15 11:24:57 +07:00
parent 8c8849365f
commit 371dcd3acd
2 changed files with 246 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
package audio
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// bridgedChannels tracks channels already registered per manager for idempotency.
var (
bridgedMu sync.Mutex
bridgedChannels = make(map[*Manager]map[string]bool)
)
// BridgeLegacySTT reads per-channel STTProxyURL from cfg and registers a
// channel-scoped proxy STT provider for each non-empty URL. Emits a deprecation
// warning once per channel per manager. Safe to call multiple times (idempotent).
//
// Channel override wins over tenant builtin_tools[stt] per Decision 2.
func BridgeLegacySTT(mgr *Manager, cfg *config.Config) {
type chanEntry struct {
Name string
URL string
Key string
TID string
TOut int
}
channels := []chanEntry{
{
Name: "telegram",
URL: cfg.Channels.Telegram.STTProxyURL,
Key: cfg.Channels.Telegram.STTAPIKey,
TID: cfg.Channels.Telegram.STTTenantID,
TOut: cfg.Channels.Telegram.STTTimeoutSeconds,
},
{
Name: "feishu",
URL: cfg.Channels.Feishu.STTProxyURL,
Key: cfg.Channels.Feishu.STTAPIKey,
TID: cfg.Channels.Feishu.STTTenantID,
TOut: cfg.Channels.Feishu.STTTimeoutSeconds,
},
{
Name: "discord",
URL: cfg.Channels.Discord.STTProxyURL,
Key: cfg.Channels.Discord.STTAPIKey,
TID: cfg.Channels.Discord.STTTenantID,
TOut: cfg.Channels.Discord.STTTimeoutSeconds,
},
}
bridgedMu.Lock()
defer bridgedMu.Unlock()
if bridgedChannels[mgr] == nil {
bridgedChannels[mgr] = make(map[string]bool)
}
already := bridgedChannels[mgr]
for _, ch := range channels {
if ch.URL == "" {
continue
}
if already[ch.Name] {
continue // idempotent — skip re-registration
}
slog.Warn("security.stt_legacy_config_deprecated",
"channel", ch.Name,
"url_suffix", lastN(ch.URL, 8),
)
p := &legacyBridgeSTTProvider{
cfg: media.STTConfig{ProxyURL: ch.URL, APIKey: ch.Key, TenantID: ch.TID, TimeoutSeconds: ch.TOut},
channel: ch.Name,
}
mgr.RegisterChannelSTT(ch.Name, p)
already[ch.Name] = true
}
}
// legacyBridgeSTTProvider is a thin adapter that wraps media.TranscribeAudio as
// an STTProvider. Defined here (not in proxy_stt) to avoid a circular import:
// proxy_stt → audio → proxy_stt.
type legacyBridgeSTTProvider struct {
cfg media.STTConfig
channel string
}
func (p *legacyBridgeSTTProvider) Name() string { return "proxy" }
func (p *legacyBridgeSTTProvider) Transcribe(ctx context.Context, in STTInput, _ STTOptions) (*TranscriptResult, error) {
filePath := in.FilePath
if filePath == "" && len(in.Bytes) > 0 {
ext := legacyExtFromMime(in.MimeType)
f, err := os.CreateTemp("", "stt-bridge-*"+ext)
if err != nil {
return nil, fmt.Errorf("legacy_bridge stt: create temp: %w", err)
}
if err := os.Chmod(f.Name(), 0600); err != nil {
f.Close()
os.Remove(f.Name())
return nil, fmt.Errorf("legacy_bridge stt: chmod: %w", err)
}
if _, err := f.Write(in.Bytes); err != nil {
f.Close()
os.Remove(f.Name())
return nil, fmt.Errorf("legacy_bridge stt: write: %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 &TranscriptResult{Text: text, Provider: "proxy"}, nil
}
// lastN returns the last n characters of s (for safe URL logging).
func lastN(s string, n int) string {
if len(s) <= n {
return s
}
return "..." + s[len(s)-n:]
}
// legacyExtFromMime returns a file extension for a MIME type.
func legacyExtFromMime(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)
}
}
+90
View File
@@ -0,0 +1,90 @@
package audio
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// newBridgeTestCfg builds a minimal config with optional channel STT URLs.
func newBridgeTestCfg(telegramURL, feishuURL, discordURL string) *config.Config {
return &config.Config{
Channels: config.ChannelsConfig{
Telegram: config.TelegramConfig{STTProxyURL: telegramURL, STTAPIKey: "tg-key"},
Feishu: config.FeishuConfig{STTProxyURL: feishuURL},
Discord: config.DiscordConfig{STTProxyURL: discordURL},
},
}
}
// Case 1: tenant has explicit chain set + channel has STTProxyURL
// → channel override wins (bridge-registered provider takes precedence).
func TestBridgeLegacySTT_ChannelOverrideWinsOverTenantChain(t *testing.T) {
// Each test gets its own manager to avoid shared bridgedChannels state.
mgr := NewManager(ManagerConfig{})
// Simulate tenant-level provider already registered and chain set.
mgr.RegisterSTT(&mockSTT{
name: "elevenlabs",
result: &TranscriptResult{Text: "tenant", Provider: "elevenlabs"},
})
mgr.SetSTTChain([]string{"elevenlabs"})
// Bridge registers telegram channel override.
cfg := newBridgeTestCfg("http://stt.example.com", "", "")
BridgeLegacySTT(mgr, cfg)
// Channel override must be registered.
if _, ok := mgr.channelSTTOverrides["telegram"]; !ok {
t.Fatal("expected telegram channel override to be registered")
}
// resolveSTTChain with telegram context returns channel override, not tenant chain.
ctx := WithChannel(context.Background(), "telegram")
chain := mgr.resolveSTTChain(ctx)
if len(chain) == 0 {
t.Fatal("expected non-empty channel override chain")
}
// Channel-scoped key format is "proxy:telegram".
if chain[0] != "proxy:telegram" {
t.Errorf("expected chain[0]='proxy:telegram', got %q", chain[0])
}
}
// Case 2: tenant has no explicit chain + channel has STTProxyURL
// → bridge registers channel-scoped provider; no-URL channels skipped.
func TestBridgeLegacySTT_RegistersWhenNoTenantChain(t *testing.T) {
mgr := NewManager(ManagerConfig{})
cfg := newBridgeTestCfg("http://stt.proxy.example.com", "http://feishu.stt.example.com", "")
BridgeLegacySTT(mgr, cfg)
if _, ok := mgr.channelSTTOverrides["telegram"]; !ok {
t.Error("expected telegram channel override registered")
}
if _, ok := mgr.channelSTTOverrides["feishu"]; !ok {
t.Error("expected feishu channel override registered")
}
// Discord URL empty → should NOT be registered.
if _, ok := mgr.channelSTTOverrides["discord"]; ok {
t.Error("expected discord channel override NOT registered (empty URL)")
}
}
// Case 3: calling BridgeLegacySTT twice is idempotent — no duplicate registrations.
func TestBridgeLegacySTT_Idempotent(t *testing.T) {
mgr := NewManager(ManagerConfig{})
cfg := newBridgeTestCfg("http://stt.example.com", "", "")
BridgeLegacySTT(mgr, cfg)
countAfterFirst := len(mgr.sttProviders)
BridgeLegacySTT(mgr, cfg)
countAfterSecond := len(mgr.sttProviders)
if countAfterSecond != countAfterFirst {
t.Errorf("idempotency violated: provider count changed from %d to %d on second call",
countAfterFirst, countAfterSecond)
}
}