mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-26 22:19:27 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user