diff --git a/internal/audio/legacy_stt_bridge.go b/internal/audio/legacy_stt_bridge.go new file mode 100644 index 00000000..a56b47e0 --- /dev/null +++ b/internal/audio/legacy_stt_bridge.go @@ -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) + } +} diff --git a/internal/audio/legacy_stt_bridge_test.go b/internal/audio/legacy_stt_bridge_test.go new file mode 100644 index 00000000..df365072 --- /dev/null +++ b/internal/audio/legacy_stt_bridge_test.go @@ -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) + } +}